diff --git a/.gitignore b/.gitignore index 8c52a76..4b8402a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ build vendor phpcs.xml .phpunit.result.cache -*/.DS_Store \ No newline at end of file +*/.DS_Store +src/assets/regions.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ef7307..af262b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog -## [1.2.1](https://github.com/contentstack/contentstack-utils-php/tree/v1.2.1) (2024-03-02) - - Support for the fragment tag in nested list +## [1.3.0](https://github.com/contentstack/contentstack-utils-php/tree/v1.3.0) (2026-06-03) + - Added `Endpoint::getContentstackEndpoint()` for dynamic region-aware URL resolution + - Added `Utils::getContentstackEndpoint()` proxy for backward-compatible access + - Bundled `regions.json` is now downloaded at `composer install` / `composer update` via `post-install-cmd`; the file is not committed to the repository + - Added runtime fallback in `Endpoint::loadRegions()` — downloads `regions.json` on first use when the file is absent (e.g. when the package is installed as a dependency) + - Added `composer refresh-regions` script to manually pull the latest regions from Contentstack + - Supports 7 regions (AWS NA/EU/AU, Azure NA/EU, GCP NA/EU) and 18 service endpoint keys + ## [1.2.0](https://github.com/contentstack/contentstack-utils-php/tree/v1.2.0) (2023-06-27) - Support for the br tag and support for nested assets in the the image ## [1.1.0](https://github.com/contentstack/contentstack-utils-php/tree/v1.1.0) (2021-07-16) diff --git a/README.md b/README.md index dc552b5..1cc820a 100644 --- a/README.md +++ b/README.md @@ -169,4 +169,243 @@ use Contentstack\Utils\Model\Option; ... $render_html_text = GQL::jsonToHtml($entry->rich_text_content,, new Option()); ... +``` + +--- + +## Endpoint Resolution + +The SDK ships with a built-in endpoint resolver that returns the correct Contentstack API URL for any region and any service — no hardcoded URLs needed. + +### How `regions.json` is provisioned + +`regions.json` is **not committed** to your project. It is downloaded automatically: + +| When | How | +|---|---| +| `composer install` or `composer update` | `post-install-cmd` runs `scripts/download-regions.php` | +| First call to `getContentstackEndpoint()` when file is missing | Runtime fallback downloads and caches the file | +| Manual refresh | `composer refresh-regions` | + +```sh +# Refresh when Contentstack adds new regions or services +composer refresh-regions +``` + +--- + +### `getContentstackEndpoint()` + +Available on both `Endpoint` and `Utils` (identical behaviour): + +```php +use Contentstack\Utils\Endpoint; +use Contentstack\Utils\Utils; + +Endpoint::getContentstackEndpoint(string $region, string $service, bool $omitHttps): string|array +Utils::getContentstackEndpoint(string $region, string $service, bool $omitHttps): string|array +``` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `$region` | `string` | `'us'` | Region ID or any accepted alias (see table below) | +| `$service` | `string` | `''` | Service key. When empty, all endpoints for the region are returned as an array | +| `$omitHttps` | `bool` | `false` | When `true`, strips `https://` from the returned URL(s) | + +--- + +### Supported Regions + +| Region | Canonical ID | Accepted Aliases | +|---|---|---| +| AWS North America | `na` | `us`, `aws-na`, `aws_na`, `NA`, `US`, `AWS-NA`, `AWS_NA` | +| AWS Europe | `eu` | `aws-eu`, `aws_eu`, `EU`, `AWS-EU`, `AWS_EU` | +| AWS Australia | `au` | `aws-au`, `aws_au`, `AU`, `AWS-AU`, `AWS_AU` | +| Azure North America | `azure-na` | `azure_na`, `AZURE-NA`, `AZURE_NA` | +| Azure Europe | `azure-eu` | `azure_eu`, `AZURE-EU`, `AZURE_EU` | +| GCP North America | `gcp-na` | `gcp_na`, `GCP-NA`, `GCP_NA` | +| GCP Europe | `gcp-eu` | `gcp_eu`, `GCP-EU`, `GCP_EU` | + +Alias matching is **case-insensitive** and accepts both `-` and `_` separators. + +--- + +### Available Service Keys + +| Key | Description | +|---|---| +| `contentDelivery` | Content Delivery API (CDN) — for fetching published entries and assets | +| `contentManagement` | Content Management API — for creating and updating content | +| `graphqlDelivery` | GraphQL Delivery API | +| `graphqlPreview` | GraphQL Live Preview | +| `preview` | REST Live Preview | +| `auth` | Authentication API | +| `application` | Contentstack web application URL | +| `images` | Image Delivery | +| `assets` | Asset Delivery | +| `automate` | Workflow Automation API | +| `launch` | Contentstack Launch API | +| `developerHub` | Developer Hub API | +| `brandKit` | Brand Kit API | +| `genAI` | Generative AI / Knowledge Vault | +| `personalizeManagement` | Personalization Management API | +| `personalizeEdge` | Personalization Edge API | +| `composableStudio` | Composable Studio API | +| `assetManagement` | Asset Management API | + +--- + +### Examples + +#### Get a single service URL + +```php +use Contentstack\Utils\Endpoint; + +// AWS North America — Content Delivery +$url = Endpoint::getContentstackEndpoint('na', 'contentDelivery'); +// → "https://cdn.contentstack.io" + +// AWS Europe — Content Management +$url = Endpoint::getContentstackEndpoint('eu', 'contentManagement'); +// → "https://eu-api.contentstack.com" + +// Azure North America — GraphQL Delivery +$url = Endpoint::getContentstackEndpoint('azure-na', 'graphqlDelivery'); +// → "https://azure-na-graphql.contentstack.com" + +// GCP Europe — Auth +$url = Endpoint::getContentstackEndpoint('gcp-eu', 'auth'); +// → "https://gcp-eu-auth-api.contentstack.com" +``` + +#### Use an alias instead of the canonical ID + +```php +// All of these return the same NA content delivery URL +Endpoint::getContentstackEndpoint('us', 'contentDelivery'); // → https://cdn.contentstack.io +Endpoint::getContentstackEndpoint('na', 'contentDelivery'); // → https://cdn.contentstack.io +Endpoint::getContentstackEndpoint('aws-na', 'contentDelivery'); // → https://cdn.contentstack.io +Endpoint::getContentstackEndpoint('AWS_NA', 'contentDelivery'); // → https://cdn.contentstack.io +``` + +#### Get a URL without the `https://` scheme + +Pass `true` as the third argument when you need just the hostname (e.g. for `Stack::setHost()`): + +```php +$host = Endpoint::getContentstackEndpoint('eu', 'contentDelivery', true); +// → "eu-cdn.contentstack.com" +``` + +#### Get all endpoints for a region + +Omit the `$service` argument to receive the full associative array: + +```php +$endpoints = Endpoint::getContentstackEndpoint('au'); +// → [ +// 'contentDelivery' => 'https://au-cdn.contentstack.com', +// 'contentManagement' => 'https://au-api.contentstack.com', +// 'graphqlDelivery' => 'https://au-graphql.contentstack.com', +// 'auth' => 'https://au-auth-api.contentstack.com', +// ...17 more keys +// ] + +// With omitHttps +$hosts = Endpoint::getContentstackEndpoint('au', '', true); +// → [ +// 'contentDelivery' => 'au-cdn.contentstack.com', +// 'contentManagement' => 'au-api.contentstack.com', +// ... +// ] +``` + +#### Via `Utils` (same result, no import change needed) + +```php +use Contentstack\Utils\Utils; + +$url = Utils::getContentstackEndpoint('gcp-na', 'contentDelivery'); +// → "https://gcp-na-cdn.contentstack.com" +``` + +--- + +### Integration with the PHP Delivery SDK + +Use `getContentstackEndpoint()` to resolve the host dynamically, then pass it to `Stack::setHost()`: + +```php +use Contentstack\Contentstack; +use Contentstack\Utils\Endpoint; + +$region = 'eu'; // change this one value to switch regions + +// Resolve the content delivery host for the chosen region +$host = Endpoint::getContentstackEndpoint($region, 'contentDelivery', true); +// → "eu-cdn.contentstack.com" + +// Initialise the delivery SDK +$stack = Contentstack::Stack('', '', ''); +$stack->setHost($host); + +// Fetch entries — all requests now go to the EU CDN +$result = $stack->ContentType('')->Query()->toJSON()->find(); +``` + +#### Switching regions without changing any other code + +```php +$regions = ['na', 'eu', 'au', 'azure-na', 'azure-eu', 'gcp-na', 'gcp-eu']; + +foreach ($regions as $region) { + $host = Endpoint::getContentstackEndpoint($region, 'contentDelivery', true); + $stack = Contentstack::Stack('', '', ''); + $stack->setHost($host); + + $result = $stack->ContentType('')->Query()->toJSON()->find(); + echo "{$region}: " . count($result[0]) . " entries\n"; +} +``` + +--- + +### Error Handling + +```php +use Contentstack\Utils\Endpoint; + +// Empty region +try { + Endpoint::getContentstackEndpoint(''); +} catch (\InvalidArgumentException $e) { + echo $e->getMessage(); + // → "Empty region provided. Please put valid region." +} + +// Unknown region +try { + Endpoint::getContentstackEndpoint('unknown-region', 'contentDelivery'); +} catch (\InvalidArgumentException $e) { + echo $e->getMessage(); + // → "Invalid region: unknown-region" +} + +// Unknown service +try { + Endpoint::getContentstackEndpoint('na', 'unknownService'); +} catch (\InvalidArgumentException $e) { + echo $e->getMessage(); + // → "Service "unknownService" not found for region "na"" +} + +// regions.json missing and no network access +try { + Endpoint::getContentstackEndpoint('na', 'contentDelivery'); +} catch (\RuntimeException $e) { + echo $e->getMessage(); + // → "contentstack/utils: regions.json not found and could not be downloaded. + // Run "composer install" or "composer refresh-regions" and ensure network access." +} ``` \ No newline at end of file diff --git a/composer.json b/composer.json index 42aa65e..d95db1a 100644 --- a/composer.json +++ b/composer.json @@ -41,9 +41,12 @@ } }, "scripts": { + "post-install-cmd": ["@php scripts/download-regions.php"], + "post-update-cmd": ["@php scripts/download-regions.php"], "test": "phpunit", "check-style": "phpcs src tests", - "fix-style": "phpcbf src tests" + "fix-style": "phpcbf src tests", + "refresh-regions": "@php scripts/download-regions.php" }, "extra": { "branch-alias": { diff --git a/docs/endpoint-integration-overview.md b/docs/endpoint-integration-overview.md new file mode 100644 index 0000000..2674edc --- /dev/null +++ b/docs/endpoint-integration-overview.md @@ -0,0 +1,359 @@ +# Endpoint Feature — Integration Overview + +## The Problem Being Solved + +Before this feature, Contentstack hosts were either **hardcoded** in the delivery SDK (`cdn.contentstack.io`) or manually constructed with string concatenation (`$region.'-cdn.contentstack.com'`). There was no single authoritative source for all regions and all services. The endpoint feature solves this by providing one function to resolve any URL for any region/service. + +--- + +## Part 1 — The Data Source (`regions.json`) + +Contentstack maintains a live registry at: +``` +https://artifacts.contentstack.com/regions.json +``` + +Structure: +``` +{ + "regions": [ + { + "id": "na", ← canonical region ID + "alias": ["us","aws-na","AWS-NA"...] ← all accepted aliases + "isDefault": true, + "endpoints": { + "contentDelivery": "https://cdn.contentstack.io", + "contentManagement": "https://api.contentstack.io", + "graphqlDelivery": "https://graphql.contentstack.com", + "auth": "https://auth-api.contentstack.com", + "preview": "https://rest-preview.contentstack.com", + ... 18 services total + } + }, + { "id": "eu", ... }, + { "id": "au", ... }, + { "id": "azure-na", ... }, + { "id": "azure-eu", ... }, + { "id": "gcp-na", ... }, + { "id": "gcp-eu", ... } ← 7 regions total + ] +} +``` + +This file is **not committed** to the repository. It is downloaded automatically at install time and lives at `src/assets/regions.json`. No runtime HTTP calls in normal operation — works fully offline once downloaded. + +--- + +## Part 2 — Keeping Regions Up To Date (`refresh-regions`) + +Contentstack occasionally adds new regions or services. The workflow to update is: + +```bash +# Pull the latest registry from Contentstack and overwrite the local file +composer refresh-regions + +# What this runs internally: +# php scripts/download-regions.php +# → curl https://artifacts.contentstack.com/regions.json +# → writes to src/assets/regions.json + +# Since regions.json is in .gitignore, no commit is needed — +# every developer and CI environment gets it fresh on composer install +``` + +This mirrors exactly how the JS SDK handles it — except JS fetches at build/publish time via an npm `prebuild` script. PHP downloads it via a `post-install-cmd` composer hook, with a runtime fallback on the first API call. + +--- + +## Part 3 — How `regions.json` Gets to Disk + +Unlike the JS SDK (which bundles the file at publish time), the PHP SDK downloads it in three layers: + +``` +Layer 1 — composer install / composer update (root package only) + │ + └── post-install-cmd fires + → @php scripts/download-regions.php + → curl https://artifacts.contentstack.com/regions.json + → writes src/assets/regions.json + → "contentstack/utils: regions.json downloaded (7 regions)." + +Layer 2 — Runtime fallback (when package is used as a dependency) + │ + └── First call to Endpoint::getContentstackEndpoint() + → file_exists('src/assets/regions.json') === false + → Endpoint::downloadAndSave() runs silently + → writes src/assets/regions.json + → continues normally + +Layer 3 — Static cache (fastest, zero I/O after first call) + │ + └── $regionsData already set in memory + → return cached data immediately + → no disk reads, no network calls +``` + +`src/assets/regions.json` is listed in `.gitignore` — it is never committed. Every environment provisions it independently. + +--- + +## Part 4 — `Endpoint::getContentstackEndpoint()` — How Resolution Works + +``` +Endpoint::getContentstackEndpoint('na', 'contentDelivery', false) + │ │ │ + │ │ └── omitHttps: keep https:// + │ └── service: which URL to return + └── region: ID or any alias +``` + +Step-by-step inside [src/Endpoint.php](../src/Endpoint.php): + +``` +Call arrives → getContentstackEndpoint('na', 'contentDelivery', false) + +1. Guard check + region === '' → throw InvalidArgumentException immediately + +2. loadRegions() [runs only once per PHP process] + First call: file_get_contents('src/assets/regions.json') + json_decode() → store in static $regionsData + Subsequent: return cached $regionsData ← no disk reads + +3. Normalize input + strtolower(trim('na')) → 'na' + +4. findRegionByIdOrAlias() + Pass 1 — match by id: + regions[0]['id'] === 'na' ✓ → return this region row + + Pass 2 — match by alias (only if Pass 1 fails): + e.g. 'AWS-NA' → strtolower → 'aws-na' + scan alias[] of each region until match found + + No match → throw InvalidArgumentException('Invalid region: ...') + +5. Service lookup + service === 'contentDelivery' + → $regionRow['endpoints']['contentDelivery'] + → "https://cdn.contentstack.io" + Key missing → throw InvalidArgumentException('Service not found') + +6. omitHttps check + false → return "https://cdn.contentstack.io" ← full URL + true → preg_replace('/^https?:\/\//', '') → "cdn.contentstack.io" + +7. No service provided (service === '') + → return entire $regionRow['endpoints'] array + → with omitHttps: strip scheme from every value +``` + +--- + +## Part 5 — `Utils::getContentstackEndpoint()` — Backward Compatibility + +[src/Utils.php](../src/Utils.php) exposes the same function as a static proxy so existing code using `Utils::` doesn't need to change import paths: + +```php +// In Utils.php — just a thin pass-through, zero logic here +public static function getContentstackEndpoint( + string $region = 'us', + string $service = '', + bool $omitHttps = false +) { + return Endpoint::getContentstackEndpoint($region, $service, $omitHttps); +} + +// Both calls produce identical results: +Endpoint::getContentstackEndpoint('na', 'contentDelivery'); +Utils::getContentstackEndpoint('na', 'contentDelivery'); +``` + +--- + +## Part 6 — Integration with the PHP Delivery SDK (what `test.php` does) + +``` +test.php execution trace: +───────────────────────────────────────────────────────────────── + +1. Load autoloaders + require vendor/autoload.php ← utils-php (has new Endpoint class) + require contentstack-php/vendor/autoload.php ← delivery SDK + (utils-php loads first → its Contentstack\Utils\* classes win) + +2. Resolve endpoint + Endpoint::getContentstackEndpoint('na', 'contentDelivery') + → "https://cdn.contentstack.io" [for display] + + Endpoint::getContentstackEndpoint('na', 'contentDelivery', true) + → "cdn.contentstack.io" [host without scheme, for setHost()] + +3. Create delivery SDK Stack + Contentstack::Stack(API_KEY, DELIVERY_TOKEN, 'production') + → Stack object, host defaults to 'cdn.contentstack.io' (NA default) + +4. Override host with endpoint-resolved value + $stack->setHost('cdn.contentstack.io') + → host is now authoritative from regions.json, not hardcoded + +5. Fetch entries + $stack->ContentType('mega_menu')->Query()->toJSON()->find() + → HTTP GET https://cdn.contentstack.io/v3/content_types/mega_menu/entries + ?environment=production + → Returns 2 entries: "Region", "Topics Navigation" + +Output: + Total entries fetched: 2 + Entry #1 → bltc85890659eefc7c2 "Region" + Entry #2 → blt3d9080b4eba8defa "Topics Navigation" +``` + +The full `test.php` source is at [test.php](../test.php). + +--- + +## Part 7 — Switching Regions in Practice + +The key benefit: changing **one string** switches every URL automatically. + +```php +// NA (default) +$host = Endpoint::getContentstackEndpoint('na', 'contentDelivery', true); +// → cdn.contentstack.io + +// EU +$host = Endpoint::getContentstackEndpoint('eu', 'contentDelivery', true); +// → eu-cdn.contentstack.com + +// Azure EU +$host = Endpoint::getContentstackEndpoint('azure-eu', 'contentDelivery', true); +// → azure-eu-cdn.contentstack.com + +// GCP NA +$host = Endpoint::getContentstackEndpoint('gcp-na', 'contentDelivery', true); +// → gcp-na-cdn.contentstack.com + +// Then the same Stack setup works for any region: +$stack = Contentstack::Stack($API_KEY, $DELIVERY_TOKEN, $ENV); +$stack->setHost($host); +``` + +Reading the region from an environment variable is the recommended pattern: + +```php +$region = getenv('CONTENTSTACK_REGION') ?: 'na'; +$host = Endpoint::getContentstackEndpoint($region, 'contentDelivery', true); + +$stack = Contentstack::Stack( + getenv('CONTENTSTACK_API_KEY'), + getenv('CONTENTSTACK_DELIVERY_TOKEN'), + getenv('CONTENTSTACK_ENVIRONMENT') +); +$stack->setHost($host); +``` + +--- + +## Part 8 — Accepted Region Aliases + +| You pass | Resolves to region | +|---|---| +| `na`, `us`, `aws-na`, `aws_na`, `NA`, `US`, `AWS-NA`, `AWS_NA` | `na` | +| `eu`, `aws-eu`, `aws_eu`, `EU`, `AWS-EU`, `AWS_EU` | `eu` | +| `au`, `aws-au`, `aws_au`, `AU`, `AWS-AU`, `AWS_AU` | `au` | +| `azure-na`, `azure_na`, `AZURE-NA`, `AZURE_NA` | `azure-na` | +| `azure-eu`, `azure_eu`, `AZURE-EU`, `AZURE_EU` | `azure-eu` | +| `gcp-na`, `gcp_na`, `GCP-NA`, `GCP_NA` | `gcp-na` | +| `gcp-eu`, `gcp_eu`, `GCP-EU`, `GCP_EU` | `gcp-eu` | + +All matching is **case-insensitive** and accepts both `-` and `_` separators. + +--- + +## Part 9 — Available Service Keys + +| Service key | What it points to | +|---|---| +| `contentDelivery` | CDN for published content (used for entry/asset fetching) | +| `contentManagement` | CMA for creating/updating content | +| `graphqlDelivery` | GraphQL delivery API | +| `graphqlPreview` | GraphQL live preview | +| `preview` | REST live preview | +| `auth` | Authentication API | +| `application` | Web app URL | +| `images` | Image delivery | +| `assets` | Asset delivery | +| `automate` | Workflow automation | +| `launch` | Contentstack Launch | +| `developerHub` | Developer Hub API | +| `brandKit` | Brand Kit API | +| `genAI` | Generative AI / Knowledge Vault | +| `personalizeManagement` | Personalization management | +| `personalizeEdge` | Personalization edge | +| `composableStudio` | Composable Studio API | +| `assetManagement` | Asset management API (NA only) | + +--- + +## Part 10 — Error Handling + +```php +use Contentstack\Utils\Endpoint; + +// Empty region string +try { + Endpoint::getContentstackEndpoint(''); +} catch (\InvalidArgumentException $e) { + // "Empty region provided. Please put valid region." +} + +// Unknown region +try { + Endpoint::getContentstackEndpoint('asia-pacific', 'contentDelivery'); +} catch (\InvalidArgumentException $e) { + // "Invalid region: asia-pacific" +} + +// Unknown service key +try { + Endpoint::getContentstackEndpoint('na', 'cms'); +} catch (\InvalidArgumentException $e) { + // "Service "cms" not found for region "na"" +} + +// regions.json missing and no network access +try { + Endpoint::getContentstackEndpoint('na', 'contentDelivery'); +} catch (\RuntimeException $e) { + // "contentstack/utils: regions.json not found and could not be downloaded. + // Run "composer install" or "composer refresh-regions" and ensure network access." +} +``` + +--- + +## Part 11 — Files Introduced by This Feature + +``` +contentstack-utils-php/ +│ +├── src/ +│ ├── Endpoint.php ← core implementation (new) +│ ├── Utils.php ← getContentstackEndpoint() proxy added +│ └── assets/ +│ └── regions.json ← downloaded at install/runtime, NOT committed +│ +├── scripts/ +│ └── download-regions.php ← called by composer hooks (new) +│ +├── tests/ +│ └── EndpointTest.php ← 39 tests, 99 assertions (new) +│ +├── docs/ +│ ├── endpoint-integration-overview.md ← this file (new) +│ └── endpoint-resolution.md ← full API reference (new) +│ +├── composer.json ← post-install-cmd, post-update-cmd, refresh-regions added +└── .gitignore ← src/assets/regions.json added +``` diff --git a/docs/endpoint-resolution.md b/docs/endpoint-resolution.md new file mode 100644 index 0000000..53fe02f --- /dev/null +++ b/docs/endpoint-resolution.md @@ -0,0 +1,838 @@ +# Endpoint Resolution — Contentstack PHP Utils SDK + +## Overview + +The endpoint resolution feature provides a single function — `getContentstackEndpoint()` — that returns the correct Contentstack API URL for any **region** and any **service**, without hardcoding host strings in your application. + +It mirrors the JavaScript utils SDK implementation and is backed by the official Contentstack regions registry at `https://artifacts.contentstack.com/regions.json`. + +--- + +## Table of Contents + +1. [How It Works](#1-how-it-works) +2. [Setup — regions.json](#2-setup--regionsjson) +3. [API Reference](#3-api-reference) +4. [Supported Regions](#4-supported-regions) +5. [Available Service Keys](#5-available-service-keys) +6. [Complete URL Reference](#6-complete-url-reference) +7. [Usage Examples](#7-usage-examples) +8. [Integration with PHP Delivery SDK](#8-integration-with-php-delivery-sdk) +9. [Error Handling](#9-error-handling) +10. [Keeping regions.json Up to Date](#10-keeping-regionsjson-up-to-date) +11. [Architecture](#11-architecture) + +--- + +## 1. How It Works + +``` +Your Code + │ + └── Endpoint::getContentstackEndpoint('eu', 'contentDelivery') + │ + ├── 1. Check static cache (zero I/O after first call) + ├── 2. Read src/assets/regions.json from disk + ├── 3. Download from artifacts.contentstack.com (fallback) + │ + ├── Normalize region → lowercase + trim + ├── Match by canonical ID → match by alias + ├── Look up service key in endpoints map + └── Return "https://eu-cdn.contentstack.com" +``` + +**Key design principles:** + +- **No hardcoded URLs** — all hosts come from `regions.json` +- **No runtime HTTP calls** in normal operation — file is read from disk +- **Zero production dependencies** — uses only PHP built-ins +- **Single static cache** — `regions.json` is parsed once per PHP process +- **Backward compatible** — available on both `Endpoint` and `Utils` classes + +--- + +## 2. Setup — regions.json + +`regions.json` is **not committed** to the repository. It is downloaded automatically and lives only on disk at `src/assets/regions.json`. + +### Automatic download on `composer install` + +```bash +composer install +# Output: +# > @php scripts/download-regions.php +# contentstack/utils: regions.json downloaded (7 regions). +``` + +This also fires on `composer update`. + +### When the package is used as a dependency + +When another project runs `composer require contentstack/utils`, Composer does not run the library's `post-install-cmd` scripts. In that case, `Endpoint::loadRegions()` detects the missing file and **downloads it automatically on the first call** — no manual step needed. + +### Manual refresh + +```bash +composer refresh-regions +# contentstack/utils: regions.json downloaded (7 regions). +``` + +Run this whenever Contentstack announces new regions or services. + +### Resolution priority + +``` +1. Static cache → fastest, zero I/O, lives for the PHP process lifetime +2. src/assets/regions.json on disk → written by composer install script +3. Live download → fallback when file is absent (e.g. fresh dependency install) +``` + +--- + +## 3. API Reference + +### `Endpoint::getContentstackEndpoint()` + +```php +namespace Contentstack\Utils; + +public static function getContentstackEndpoint( + string $region = 'us', // Region ID or alias + string $service = '', // Service key. Empty = return all endpoints + bool $omitHttps = false // true = strip https:// from result +): string|array +``` + +Also available as a proxy on `Utils`: + +```php +use Contentstack\Utils\Utils; + +Utils::getContentstackEndpoint(string $region, string $service, bool $omitHttps): string|array +``` + +Both calls produce identical results. + +### Parameters + +| Parameter | Type | Default | Required | Description | +|---|---|---|---|---| +| `$region` | `string` | `'us'` | No | Region ID (`na`, `eu`, `au`, `azure-na`, `azure-eu`, `gcp-na`, `gcp-eu`) or any accepted alias. Case-insensitive. | +| `$service` | `string` | `''` | No | Service key (e.g. `contentDelivery`, `contentManagement`). When empty, the full endpoint map for the region is returned as an associative array. | +| `$omitHttps` | `bool` | `false` | No | When `true`, strips `https://` from every returned URL. Useful when passing the host to `Stack::setHost()`. | + +### Return values + +| `$service` | `$omitHttps` | Return type | Example | +|---|---|---|---| +| `'contentDelivery'` | `false` | `string` | `"https://eu-cdn.contentstack.com"` | +| `'contentDelivery'` | `true` | `string` | `"eu-cdn.contentstack.com"` | +| `''` (empty) | `false` | `array` | `['contentDelivery' => 'https://...', ...]` | +| `''` (empty) | `true` | `array` | `['contentDelivery' => 'eu-cdn...', ...]` | + +### Exceptions + +| Exception | Thrown when | +|---|---| +| `\InvalidArgumentException` | `$region` is an empty string | +| `\InvalidArgumentException` | `$region` does not match any known ID or alias | +| `\InvalidArgumentException` | `$service` is not found in the region's endpoint map | +| `\RuntimeException` | `regions.json` is missing and cannot be downloaded | +| `\RuntimeException` | `regions.json` exists but contains invalid JSON | + +--- + +## 4. Supported Regions + +| Canonical ID | Cloud | Location | Default | Accepted Aliases | +|---|---|---|---|---| +| `na` | AWS | North America | ✓ | `us`, `aws-na`, `aws_na`, `NA`, `US`, `AWS-NA`, `AWS_NA` | +| `eu` | AWS | Europe | | `aws-eu`, `aws_eu`, `EU`, `AWS-EU`, `AWS_EU` | +| `au` | AWS | Australia | | `aws-au`, `aws_au`, `AU`, `AWS-AU`, `AWS_AU` | +| `azure-na` | Azure | North America | | `azure_na`, `AZURE-NA`, `AZURE_NA` | +| `azure-eu` | Azure | Europe | | `azure_eu`, `AZURE-EU`, `AZURE_EU` | +| `gcp-na` | GCP | North America | | `gcp_na`, `GCP-NA`, `GCP_NA` | +| `gcp-eu` | GCP | Europe | | `gcp_eu`, `GCP-EU`, `GCP_EU` | + +**Alias matching rules:** +- Case-insensitive — `EU`, `eu`, `Eu` all resolve to the same region +- Both `-` and `_` separators are accepted — `azure-na` and `azure_na` are equivalent +- Leading/trailing whitespace is stripped automatically + +--- + +## 5. Available Service Keys + +| Key | Description | +|---|---| +| `contentDelivery` | Content Delivery API (CDN) — for fetching published entries and assets | +| `contentManagement` | Content Management API — for creating, updating, and deleting content | +| `graphqlDelivery` | GraphQL Delivery API | +| `graphqlPreview` | GraphQL Live Preview API | +| `preview` | REST Live Preview API | +| `auth` | Authentication API | +| `application` | Contentstack web application | +| `images` | Image Delivery — optimised image serving | +| `assets` | Asset Delivery — non-image file storage | +| `automate` | Workflow Automation API | +| `launch` | Contentstack Launch API | +| `developerHub` | Developer Hub API | +| `brandKit` | Brand Kit API | +| `genAI` | Generative AI / Knowledge Vault | +| `personalizeManagement` | Personalization Management API | +| `personalizeEdge` | Personalization Edge API | +| `composableStudio` | Composable Studio API | +| `assetManagement` | Asset Management API (NA only) | + +> **Note:** Not all service keys are present in every region. If you request a service that does not exist for a given region, an `InvalidArgumentException` is thrown. + +--- + +## 6. Complete URL Reference + +### AWS North America (`na`) + +| Service | URL | +|---|---| +| `application` | `https://app.contentstack.com` | +| `contentDelivery` | `https://cdn.contentstack.io` | +| `contentManagement` | `https://api.contentstack.io` | +| `auth` | `https://auth-api.contentstack.com` | +| `graphqlDelivery` | `https://graphql.contentstack.com` | +| `preview` | `https://rest-preview.contentstack.com` | +| `graphqlPreview` | `https://graphql-preview.contentstack.com` | +| `images` | `https://images.contentstack.io` | +| `assets` | `https://assets.contentstack.io` | +| `automate` | `https://automations-api.contentstack.com` | +| `launch` | `https://launch-api.contentstack.com` | +| `developerHub` | `https://developerhub-api.contentstack.com` | +| `brandKit` | `https://brand-kits-api.contentstack.com` | +| `genAI` | `https://ai.contentstack.com/brand-kits` | +| `personalizeManagement` | `https://personalize-api.contentstack.com` | +| `personalizeEdge` | `https://personalize-edge.contentstack.com` | +| `composableStudio` | `https://composable-studio-api.contentstack.com` | +| `assetManagement` | `https://am-api.contentstack.com` | + +### AWS Europe (`eu`) + +| Service | URL | +|---|---| +| `application` | `https://eu-app.contentstack.com` | +| `contentDelivery` | `https://eu-cdn.contentstack.com` | +| `contentManagement` | `https://eu-api.contentstack.com` | +| `auth` | `https://eu-auth-api.contentstack.com` | +| `graphqlDelivery` | `https://eu-graphql.contentstack.com` | +| `preview` | `https://eu-rest-preview.contentstack.com` | +| `graphqlPreview` | `https://eu-graphql-preview.contentstack.com` | +| `images` | `https://eu-images.contentstack.com` | +| `assets` | `https://eu-assets.contentstack.com` | +| `automate` | `https://eu-prod-automations-api.contentstack.com` | +| `launch` | `https://eu-launch-api.contentstack.com` | +| `developerHub` | `https://eu-developerhub-api.contentstack.com` | +| `brandKit` | `https://eu-brand-kits-api.contentstack.com` | +| `genAI` | `https://eu-ai.contentstack.com/brand-kits` | +| `personalizeManagement` | `https://eu-personalize-api.contentstack.com` | +| `personalizeEdge` | `https://eu-personalize-edge.contentstack.com` | +| `composableStudio` | `https://eu-composable-studio-api.contentstack.com` | + +### AWS Australia (`au`) + +| Service | URL | +|---|---| +| `contentDelivery` | `https://au-cdn.contentstack.com` | +| `contentManagement` | `https://au-api.contentstack.com` | +| `auth` | `https://au-auth-api.contentstack.com` | +| `graphqlDelivery` | `https://au-graphql.contentstack.com` | +| `preview` | `https://au-rest-preview.contentstack.com` | +| `graphqlPreview` | `https://au-graphql-preview.contentstack.com` | +| `images` | `https://au-images.contentstack.com` | +| `assets` | `https://au-assets.contentstack.com` | + +### Azure North America (`azure-na`) + +| Service | URL | +|---|---| +| `contentDelivery` | `https://azure-na-cdn.contentstack.com` | +| `contentManagement` | `https://azure-na-api.contentstack.com` | +| `auth` | `https://azure-na-auth-api.contentstack.com` | +| `graphqlDelivery` | `https://azure-na-graphql.contentstack.com` | +| `preview` | `https://azure-na-rest-preview.contentstack.com` | +| `graphqlPreview` | `https://azure-na-graphql-preview.contentstack.com` | +| `images` | `https://azure-na-images.contentstack.com` | +| `assets` | `https://azure-na-assets.contentstack.com` | + +### Azure Europe (`azure-eu`) + +| Service | URL | +|---|---| +| `contentDelivery` | `https://azure-eu-cdn.contentstack.com` | +| `contentManagement` | `https://azure-eu-api.contentstack.com` | +| `auth` | `https://azure-eu-auth-api.contentstack.com` | +| `graphqlDelivery` | `https://azure-eu-graphql.contentstack.com` | + +### GCP North America (`gcp-na`) + +| Service | URL | +|---|---| +| `contentDelivery` | `https://gcp-na-cdn.contentstack.com` | +| `contentManagement` | `https://gcp-na-api.contentstack.com` | +| `auth` | `https://gcp-na-auth-api.contentstack.com` | +| `graphqlDelivery` | `https://gcp-na-graphql.contentstack.com` | + +### GCP Europe (`gcp-eu`) + +| Service | URL | +|---|---| +| `contentDelivery` | `https://gcp-eu-cdn.contentstack.com` | +| `contentManagement` | `https://gcp-eu-api.contentstack.com` | +| `auth` | `https://gcp-eu-auth-api.contentstack.com` | +| `graphqlDelivery` | `https://gcp-eu-graphql.contentstack.com` | + +--- + +## 7. Usage Examples + +### Basic — get a single URL + +```php +use Contentstack\Utils\Endpoint; + +// Full URL with https:// +$url = Endpoint::getContentstackEndpoint('na', 'contentDelivery'); +// → "https://cdn.contentstack.io" + +$url = Endpoint::getContentstackEndpoint('eu', 'contentManagement'); +// → "https://eu-api.contentstack.com" + +$url = Endpoint::getContentstackEndpoint('au', 'graphqlDelivery'); +// → "https://au-graphql.contentstack.com" + +$url = Endpoint::getContentstackEndpoint('azure-na', 'auth'); +// → "https://azure-na-auth-api.contentstack.com" + +$url = Endpoint::getContentstackEndpoint('gcp-eu', 'preview'); +// → "https://gcp-eu-rest-preview.contentstack.com" +``` + +### Using region aliases + +All aliases resolve to the same canonical region — use whichever form suits your config: + +```php +// All four return "https://cdn.contentstack.io" +Endpoint::getContentstackEndpoint('na', 'contentDelivery'); +Endpoint::getContentstackEndpoint('us', 'contentDelivery'); +Endpoint::getContentstackEndpoint('aws-na', 'contentDelivery'); +Endpoint::getContentstackEndpoint('AWS_NA', 'contentDelivery'); + +// All three return "https://eu-cdn.contentstack.com" +Endpoint::getContentstackEndpoint('eu', 'contentDelivery'); +Endpoint::getContentstackEndpoint('EU', 'contentDelivery'); +Endpoint::getContentstackEndpoint('aws_eu', 'contentDelivery'); + +// Both return "https://azure-na-cdn.contentstack.com" +Endpoint::getContentstackEndpoint('azure-na', 'contentDelivery'); +Endpoint::getContentstackEndpoint('AZURE_NA', 'contentDelivery'); +``` + +### Strip `https://` for use as a hostname + +Pass `true` as the third argument when you need just the host: + +```php +$host = Endpoint::getContentstackEndpoint('eu', 'contentDelivery', true); +// → "eu-cdn.contentstack.com" + +$host = Endpoint::getContentstackEndpoint('gcp-na', 'contentManagement', true); +// → "gcp-na-api.contentstack.com" + +$host = Endpoint::getContentstackEndpoint('azure-eu', 'auth', true); +// → "azure-eu-auth-api.contentstack.com" +``` + +### Get all endpoints for a region + +Omit `$service` to receive the complete associative array: + +```php +$endpoints = Endpoint::getContentstackEndpoint('eu'); +// → [ +// 'application' => 'https://eu-app.contentstack.com', +// 'contentDelivery' => 'https://eu-cdn.contentstack.com', +// 'contentManagement' => 'https://eu-api.contentstack.com', +// 'auth' => 'https://eu-auth-api.contentstack.com', +// 'graphqlDelivery' => 'https://eu-graphql.contentstack.com', +// 'preview' => 'https://eu-rest-preview.contentstack.com', +// 'graphqlPreview' => 'https://eu-graphql-preview.contentstack.com', +// 'images' => 'https://eu-images.contentstack.com', +// 'assets' => 'https://eu-assets.contentstack.com', +// 'automate' => 'https://eu-prod-automations-api.contentstack.com', +// 'launch' => 'https://eu-launch-api.contentstack.com', +// 'developerHub' => 'https://eu-developerhub-api.contentstack.com', +// 'brandKit' => 'https://eu-brand-kits-api.contentstack.com', +// 'genAI' => 'https://eu-ai.contentstack.com/brand-kits', +// 'personalizeManagement'=> 'https://eu-personalize-api.contentstack.com', +// 'personalizeEdge' => 'https://eu-personalize-edge.contentstack.com', +// 'composableStudio' => 'https://eu-composable-studio-api.contentstack.com', +// ] + +// With omitHttps — all schemes stripped +$hosts = Endpoint::getContentstackEndpoint('eu', '', true); +// → [ +// 'contentDelivery' => 'eu-cdn.contentstack.com', +// 'contentManagement' => 'eu-api.contentstack.com', +// ... +// ] + +echo $endpoints['contentDelivery']; // https://eu-cdn.contentstack.com +echo $hosts['contentManagement']; // eu-api.contentstack.com +``` + +### Reading region from an environment variable + +```php +use Contentstack\Utils\Endpoint; + +// Set in your .env or server config: CONTENTSTACK_REGION=eu +$region = getenv('CONTENTSTACK_REGION') ?: 'na'; + +$cdnUrl = Endpoint::getContentstackEndpoint($region, 'contentDelivery'); +$apiUrl = Endpoint::getContentstackEndpoint($region, 'contentManagement'); + +echo $cdnUrl; // https://eu-cdn.contentstack.com +echo $apiUrl; // https://eu-api.contentstack.com +``` + +### Accessing specific services from the full map + +```php +$endpoints = Endpoint::getContentstackEndpoint('azure-na'); + +$cdnUrl = $endpoints['contentDelivery']; +$apiUrl = $endpoints['contentManagement']; +$graphqlUrl = $endpoints['graphqlDelivery']; +$previewUrl = $endpoints['preview']; +$authUrl = $endpoints['auth']; + +// Use in your app config +$config = [ + 'cdn' => $cdnUrl, + 'api' => $apiUrl, + 'graphql' => $graphqlUrl, +]; +``` + +### Via `Utils` — backward-compatible shorthand + +```php +use Contentstack\Utils\Utils; + +// Identical to Endpoint::getContentstackEndpoint() +$url = Utils::getContentstackEndpoint('eu', 'contentDelivery'); +// → "https://eu-cdn.contentstack.com" + +$host = Utils::getContentstackEndpoint('gcp-na', 'contentManagement', true); +// → "gcp-na-api.contentstack.com" + +$all = Utils::getContentstackEndpoint('au'); +// → associative array of all AU endpoints +``` + +--- + +## 8. Integration with PHP Delivery SDK + +The `contentDelivery` host resolved from `getContentstackEndpoint()` maps directly to the host the PHP delivery SDK uses for entry and asset fetching. + +### Basic setup + +```php +use Contentstack\Contentstack; +use Contentstack\Utils\Endpoint; + +$region = 'eu'; // switch this one value to target any region + +// Resolve the content delivery host for the chosen region +$host = Endpoint::getContentstackEndpoint($region, 'contentDelivery', true); +// → "eu-cdn.contentstack.com" + +// Initialise the delivery SDK +$stack = Contentstack::Stack( + '', + '', + '' +); + +// Wire the resolved host into the stack +$stack->setHost($host); + +// All subsequent requests go to the EU CDN +$result = $stack + ->ContentType('') + ->Query() + ->toJSON() + ->find(); + +foreach ($result[0] as $entry) { + echo $entry['title'] . "\n"; +} +``` + +### Reading region from environment variable (recommended) + +```php +use Contentstack\Contentstack; +use Contentstack\Utils\Endpoint; + +$region = getenv('CONTENTSTACK_REGION') ?: 'na'; + +$stack = Contentstack::Stack( + getenv('CONTENTSTACK_API_KEY'), + getenv('CONTENTSTACK_DELIVERY_TOKEN'), + getenv('CONTENTSTACK_ENVIRONMENT') +); +$stack->setHost(Endpoint::getContentstackEndpoint($region, 'contentDelivery', true)); +``` + +`.env` file: +```dotenv +CONTENTSTACK_REGION=eu +CONTENTSTACK_API_KEY=blt... +CONTENTSTACK_DELIVERY_TOKEN=cs... +CONTENTSTACK_ENVIRONMENT=production +``` + +### Fetching a single entry + +```php +use Contentstack\Contentstack; +use Contentstack\Utils\Endpoint; + +$region = 'azure-na'; +$host = Endpoint::getContentstackEndpoint($region, 'contentDelivery', true); + +$stack = Contentstack::Stack('', '', ''); +$stack->setHost($host); + +$entry = $stack + ->ContentType('') + ->Entry('') + ->toJSON() + ->fetch(); + +echo $entry['title']; +``` + +### Querying entries with filters + +```php +use Contentstack\Contentstack; +use Contentstack\Utils\Endpoint; + +$host = Endpoint::getContentstackEndpoint('gcp-eu', 'contentDelivery', true); +$stack = Contentstack::Stack('', '', ''); +$stack->setHost($host); + +$result = $stack + ->ContentType('blog_post') + ->Query() + ->where('category', 'technology') + ->limit(10) + ->toJSON() + ->find(); + +$entries = $result[0]; +echo "Found: " . count($entries) . " entries\n"; +``` + +### Fetching assets + +```php +use Contentstack\Contentstack; +use Contentstack\Utils\Endpoint; + +$host = Endpoint::getContentstackEndpoint('eu', 'contentDelivery', true); +$stack = Contentstack::Stack('', '', ''); +$stack->setHost($host); + +$asset = $stack->Assets('')->toJSON()->fetch(); +echo $asset['url']; +``` + +### Querying with embedded items (JSON RTE) + +```php +use Contentstack\Contentstack; +use Contentstack\Utils\Endpoint; +use Contentstack\Utils\Model\Option; + +$host = Endpoint::getContentstackEndpoint('na', 'contentDelivery', true); +$stack = Contentstack::Stack('', '', ''); +$stack->setHost($host); + +$result = $stack + ->ContentType('') + ->Query() + ->toJSON() + ->includeEmbeddedItems() + ->find(); + +foreach ($result[0] as $entry) { + $html = Contentstack::jsonToHtml($entry['json_rte_field'], new Option($entry)); + echo $html; +} +``` + +### GraphQL with endpoint resolution + +```php +use Contentstack\Utils\Endpoint; + +$graphqlUrl = Endpoint::getContentstackEndpoint('eu', 'graphqlDelivery'); +// → "https://eu-graphql.contentstack.com" + +// Use this URL as the base for your GraphQL client +$client = new GraphQLClient($graphqlUrl, [ + 'headers' => [ + 'access_token' => '', + 'api_key' => '', + ] +]); +``` + +### Switching regions dynamically + +Change one variable to redirect all API traffic to a different region: + +```php +use Contentstack\Contentstack; +use Contentstack\Utils\Endpoint; + +function createStack(string $region): \Contentstack\Stack\Stack +{ + $host = Endpoint::getContentstackEndpoint($region, 'contentDelivery', true); + $stack = Contentstack::Stack( + getenv('CONTENTSTACK_API_KEY'), + getenv('CONTENTSTACK_DELIVERY_TOKEN'), + getenv('CONTENTSTACK_ENVIRONMENT') + ); + $stack->setHost($host); + return $stack; +} + +// Route traffic by region +$regions = ['na', 'eu', 'au', 'azure-na', 'azure-eu', 'gcp-na', 'gcp-eu']; + +foreach ($regions as $region) { + $stack = createStack($region); + $result = $stack->ContentType('page')->Query()->toJSON()->find(); + $count = count($result[0]); + $host = Endpoint::getContentstackEndpoint($region, 'contentDelivery'); + echo sprintf("%-10s %-45s %d entries\n", $region, $host, $count); +} +``` + +--- + +## 9. Error Handling + +All exceptions thrown are either `\InvalidArgumentException` (bad input) or `\RuntimeException` (infrastructure/file problem). + +### Empty region + +```php +use Contentstack\Utils\Endpoint; + +try { + Endpoint::getContentstackEndpoint(''); +} catch (\InvalidArgumentException $e) { + echo $e->getMessage(); + // "Empty region provided. Please put valid region." +} +``` + +### Unknown region + +```php +try { + Endpoint::getContentstackEndpoint('asia-pacific', 'contentDelivery'); +} catch (\InvalidArgumentException $e) { + echo $e->getMessage(); + // "Invalid region: asia-pacific" +} +``` + +### Unknown service key + +```php +try { + Endpoint::getContentstackEndpoint('na', 'cms'); +} catch (\InvalidArgumentException $e) { + echo $e->getMessage(); + // 'Service "cms" not found for region "na"' +} +``` + +### regions.json missing and no network + +```php +try { + Endpoint::getContentstackEndpoint('na', 'contentDelivery'); +} catch (\RuntimeException $e) { + echo $e->getMessage(); + // "contentstack/utils: regions.json not found and could not be downloaded. + // Run "composer install" or "composer refresh-regions" and ensure network access." +} +``` + +### Corrupt regions.json + +```php +try { + Endpoint::getContentstackEndpoint('na', 'contentDelivery'); +} catch (\RuntimeException $e) { + echo $e->getMessage(); + // "contentstack/utils: regions.json is corrupt. + // Run "composer refresh-regions" to re-download it." +} +``` + +### Defensive pattern for production code + +```php +use Contentstack\Utils\Endpoint; + +function resolveHost(string $region, string $service): string +{ + try { + return Endpoint::getContentstackEndpoint($region, $service, true); + } catch (\InvalidArgumentException $e) { + // Bad config — log and fall back to default NA host + error_log('Endpoint config error: ' . $e->getMessage()); + return 'cdn.contentstack.io'; + } catch (\RuntimeException $e) { + // Infrastructure problem — log and fall back + error_log('Endpoint load error: ' . $e->getMessage()); + return 'cdn.contentstack.io'; + } +} +``` + +--- + +## 10. Keeping regions.json Up to Date + +Contentstack occasionally adds new regions or new service keys. The bundled `regions.json` needs to be refreshed when this happens. + +### Refresh manually + +```bash +composer refresh-regions +``` + +### Automate in CI/CD + +Add a refresh step before your deploy so the latest regions are always used: + +```yaml +# GitHub Actions example +- name: Install PHP dependencies + run: composer install --no-dev --optimize-autoloader +# regions.json is downloaded automatically by post-install-cmd + +# Or refresh explicitly if the file was cached between CI runs +- name: Refresh Contentstack regions + run: composer refresh-regions +``` + +### How the download script works + +`scripts/download-regions.php` is the script wired to the composer hooks: + +1. Tries **PHP curl extension** first — follows redirects, verifies SSL +2. Falls back to **`file_get_contents`** with a stream context +3. Validates the downloaded JSON has a `regions` array before writing +4. Writes to `src/assets/regions.json` +5. Prints the region count on success; warns (non-fatal) on failure + +The exit code is always `0` — a download failure is a warning, not a fatal error, because the runtime fallback in `Endpoint::loadRegions()` will attempt the download again on the first API call. + +--- + +## 11. Architecture + +### File structure + +``` +contentstack-utils-php/ +├── src/ +│ ├── Endpoint.php ← core implementation +│ ├── Utils.php ← proxy method for backward compat +│ └── assets/ +│ └── regions.json ← downloaded at install/runtime, NOT committed +├── scripts/ +│ └── download-regions.php ← called by composer post-install-cmd +├── tests/ +│ └── EndpointTest.php ← 39 tests, 99 assertions +└── composer.json ← post-install-cmd, post-update-cmd, refresh-regions +``` + +### `Endpoint.php` internal flow + +``` +getContentstackEndpoint($region, $service, $omitHttps) +│ +├── Guard: $region === '' → throw InvalidArgumentException +│ +├── loadRegions() +│ ├── $regionsData cached? → return cache (zero I/O) +│ ├── file_exists(regions.json)? → read + decode + cache +│ └── else → downloadAndSave() → read + decode + cache +│ +├── strtolower(trim($region)) → $normalized +│ +├── findRegionByIdOrAlias($regions, $normalized) +│ ├── Pass 1: match $row['id'] === $normalized +│ └── Pass 2: match strtolower($alias) === $normalized for each alias +│ └── null → throw InvalidArgumentException('Invalid region') +│ +├── $service provided? +│ ├── YES → $regionRow['endpoints'][$service] +│ │ └── missing? → throw InvalidArgumentException('Service not found') +│ │ └── omitHttps? → stripHttps($url) : $url +│ └── NO → $regionRow['endpoints'] +│ └── omitHttps? → stripHttpsFromMap($endpoints) : $endpoints +│ +└── return string|array +``` + +### Static cache lifetime + +`Endpoint::$regionsData` is a `static` class property. In PHP: +- It is initialised to `null` +- Set on the first `loadRegions()` call +- Persists for the entire PHP process lifetime (e.g. the full HTTP request in FPM, or the full CLI run) +- Reset explicitly via `Endpoint::resetCache()` (test use only) + +This means `regions.json` is read from disk **once per process**, regardless of how many times `getContentstackEndpoint()` is called. + +### Relationship between `Endpoint` and `Utils` + +``` +Utils::getContentstackEndpoint() ← thin proxy, no logic + │ + └── Endpoint::getContentstackEndpoint() ← all logic lives here +``` + +`Utils` delegates entirely to `Endpoint`. Both classes are in the `Contentstack\Utils` namespace so no `use` import is needed between them. + +### Composer hooks summary + +| Hook | When it fires | What it does | +|---|---|---| +| `post-install-cmd` | After `composer install` on the **root** package | Runs `scripts/download-regions.php` | +| `post-update-cmd` | After `composer update` on the **root** package | Runs `scripts/download-regions.php` | +| `refresh-regions` | `composer refresh-regions` (manual) | Runs `scripts/download-regions.php` | +| Runtime fallback | First `getContentstackEndpoint()` call when file is missing | `Endpoint::downloadAndSave()` downloads the file silently | + +> **Important:** `post-install-cmd` and `post-update-cmd` only fire when this package **is the root** (i.e. being developed directly). When another project runs `composer require contentstack/utils`, those hooks are skipped — the runtime fallback handles the download transparently. diff --git a/scripts/download-regions.php b/scripts/download-regions.php new file mode 100644 index 0000000..23404bd --- /dev/null +++ b/scripts/download-regions.php @@ -0,0 +1,79 @@ + true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => true, + ]); + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curlError = curl_error($ch); + curl_close($ch); + + if ($response !== false && $httpCode === 200) { + $data = $response; + } elseif ($curlError) { + fwrite(STDERR, "contentstack/utils: curl error: {$curlError}\n"); + } +} + +// --- Attempt 2: file_get_contents fallback ---------------------------------- +if ($data === null) { + $ctx = stream_context_create([ + 'http' => [ + 'timeout' => 30, + 'ignore_errors' => false, + ], + 'ssl' => [ + 'verify_peer' => true, + 'verify_peer_name' => true, + ], + ]); + $data = @file_get_contents($url, false, $ctx); +} + +// --- Validate and write ------------------------------------------------------ +if ($data === false || $data === null) { + fwrite(STDERR, "contentstack/utils: Warning — could not download regions.json. " . + "The SDK will attempt to download it at runtime on first use.\n"); + exit(0); // non-fatal: runtime fallback in Endpoint::loadRegions() handles it +} + +$decoded = json_decode($data, true); +if (!is_array($decoded) || !isset($decoded['regions']) || !is_array($decoded['regions'])) { + fwrite(STDERR, "contentstack/utils: Warning — downloaded data is not a valid regions.json.\n"); + exit(0); +} + +if (file_put_contents($dest, $data) === false) { + fwrite(STDERR, "contentstack/utils: Warning — could not write regions.json to {$dest}.\n"); + exit(0); +} + +$regionCount = count($decoded['regions']); +echo "contentstack/utils: regions.json downloaded ({$regionCount} regions).\n"; diff --git a/src/Endpoint.php b/src/Endpoint.php new file mode 100644 index 0000000..cf00c4e --- /dev/null +++ b/src/Endpoint.php @@ -0,0 +1,213 @@ +|null */ + private static $regionsData = null; + + /** + * Resolve a Contentstack service endpoint URL for a given region. + * + * @param string $region Region ID or alias (e.g. 'us', 'eu', 'azure-na', 'gcp-eu'). + * Defaults to 'us' (AWS North America). + * @param string $service Optional service key (e.g. 'contentDelivery', 'contentManagement', + * 'auth', 'graphqlDelivery'). When empty, all endpoints are returned. + * @param bool $omitHttps When true, strips the 'https://' prefix from every URL. + * + * @return string|array Single URL string when $service is provided, + * associative array of all service URLs otherwise. + * + * @throws \InvalidArgumentException When region is empty, unknown, or service is not found. + * @throws \RuntimeException When the bundled regions.json cannot be read or parsed. + */ + public static function getContentstackEndpoint( + string $region = 'us', + string $service = '', + bool $omitHttps = false + ) { + if ($region === '') { + throw new \InvalidArgumentException( + 'Empty region provided. Please put valid region.' + ); + } + + $data = self::loadRegions(); + $normalized = strtolower(trim($region)); + $regionRow = self::findRegionByIdOrAlias($data['regions'], $normalized); + + if ($regionRow === null) { + throw new \InvalidArgumentException("Invalid region: {$region}"); + } + + if ($service !== '') { + if (!array_key_exists($service, $regionRow['endpoints'])) { + throw new \InvalidArgumentException( + "Service \"{$service}\" not found for region \"{$regionRow['id']}\"" + ); + } + $url = $regionRow['endpoints'][$service]; + return $omitHttps ? self::stripHttps($url) : $url; + } + + $endpoints = $regionRow['endpoints']; + return $omitHttps ? self::stripHttpsFromMap($endpoints) : $endpoints; + } + + /** @var string */ + const REGIONS_URL = 'https://artifacts.contentstack.com/regions.json'; + + /** + * Load and cache regions.json. + * + * Resolution order: + * 1. In-memory static cache (fastest, zero I/O after first call) + * 2. src/assets/regions.json on disk (written by composer install script) + * 3. Live download from artifacts.contentstack.com (fallback when the + * package is used as a dependency and the file was not yet created) + * + * @return array + */ + private static function loadRegions(): array + { + if (self::$regionsData !== null) { + return self::$regionsData; + } + + $path = __DIR__ . '/assets/regions.json'; + + if (!file_exists($path)) { + self::downloadAndSave($path); + } + + if (!file_exists($path)) { + throw new \RuntimeException( + 'contentstack/utils: regions.json not found and could not be downloaded. ' . + 'Run "composer install" or "composer refresh-regions" and ensure network access.' + ); + } + + $raw = file_get_contents($path); + if ($raw === false) { + throw new \RuntimeException( + 'contentstack/utils: Could not read regions.json.' + ); + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded) || !isset($decoded['regions'])) { + throw new \RuntimeException( + 'contentstack/utils: regions.json is corrupt. ' . + 'Run "composer refresh-regions" to re-download it.' + ); + } + + self::$regionsData = $decoded; + return self::$regionsData; + } + + /** + * Download regions.json from the Contentstack CDN and write it to disk. + * Tries the PHP curl extension first, falls back to file_get_contents. + * Silent on failure — caller decides whether the missing file is fatal. + * + * @param string $dest Absolute path to write the file to. + */ + private static function downloadAndSave(string $dest): void + { + $dir = dirname($dest); + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $data = null; + + if (extension_loaded('curl')) { + $ch = curl_init(self::REGIONS_URL); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_SSL_VERIFYPEER => true, + ]); + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($response !== false && $httpCode === 200) { + $data = $response; + } + } + + if ($data === null) { + $ctx = stream_context_create(['http' => ['timeout' => 30]]); + $data = @file_get_contents(self::REGIONS_URL, false, $ctx); + } + + if (!$data) { + return; + } + + $decoded = json_decode($data, true); + if (is_array($decoded) && isset($decoded['regions'])) { + file_put_contents($dest, $data); + } + } + + /** + * Find a region entry by its id or any of its aliases (case-insensitive). + * + * @param array> $regions + * @param string $input Already lowercased input. + * @return array|null + */ + private static function findRegionByIdOrAlias(array $regions, string $input): ?array + { + foreach ($regions as $row) { + if ($row['id'] === $input) { + return $row; + } + } + foreach ($regions as $row) { + foreach ($row['alias'] as $alias) { + if (strtolower($alias) === $input) { + return $row; + } + } + } + return null; + } + + /** + * Strip the https:// (or http://) scheme from a URL string. + */ + private static function stripHttps(string $url): string + { + return (string) preg_replace('/^https?:\/\//', '', $url); + } + + /** + * Strip https:// from every value in an endpoint map. + * + * @param array $endpoints + * @return array + */ + private static function stripHttpsFromMap(array $endpoints): array + { + $result = []; + foreach ($endpoints as $key => $url) { + $result[$key] = self::stripHttps($url); + } + return $result; + } + + /** + * Reset the internal region cache (intended for testing only). + */ + public static function resetCache(): void + { + self::$regionsData = null; + } +} diff --git a/src/Utils.php b/src/Utils.php index d18479d..9d377fe 100644 --- a/src/Utils.php +++ b/src/Utils.php @@ -84,6 +84,24 @@ public static function jsonToHtml(object $content, Option $option): string { return $resultHtml; } + /** + * Resolve a Contentstack service endpoint URL for a given region. + * + * @param string $region Region ID or alias (e.g. 'us', 'eu', 'azure-na', 'gcp-eu'). + * @param string $service Optional service key (e.g. 'contentDelivery', 'contentManagement'). + * When empty, all endpoints for the region are returned as an array. + * @param bool $omitHttps When true, strips the 'https://' prefix from returned URL(s). + * + * @return string|array + */ + public static function getContentstackEndpoint( + string $region = 'us', + string $service = '', + bool $omitHttps = false + ) { + return Endpoint::getContentstackEndpoint($region, $service, $omitHttps); + } + protected static function findObject(Metadata $metadata, array $entry): array { if (array_key_exists('_embedded_items', $entry)) diff --git a/tests/EndpointTest.php b/tests/EndpointTest.php new file mode 100644 index 0000000..5885311 --- /dev/null +++ b/tests/EndpointTest.php @@ -0,0 +1,253 @@ +assertIsArray($endpoints); + $this->assertArrayHasKey('contentDelivery', $endpoints); + $this->assertArrayHasKey('contentManagement', $endpoints); + } + + public function testDefaultRegionContentDelivery(): void + { + $url = Endpoint::getContentstackEndpoint('us', 'contentDelivery'); + $this->assertSame('https://cdn.contentstack.io', $url); + } + + public function testDefaultRegionContentManagement(): void + { + $url = Endpoint::getContentstackEndpoint('us', 'contentManagement'); + $this->assertSame('https://api.contentstack.io', $url); + } + + // ------------------------------------------------------------------------- + // Region aliases resolve to the same region + // ------------------------------------------------------------------------- + + /** + * @dataProvider naAliasProvider + */ + public function testNaRegionAliasesResolveToSameEndpoint(string $alias): void + { + $url = Endpoint::getContentstackEndpoint($alias, 'contentDelivery'); + $this->assertSame('https://cdn.contentstack.io', $url); + } + + public function naAliasProvider(): array + { + return [ + 'id na' => ['na'], + 'alias us' => ['us'], + 'alias aws-na' => ['aws-na'], + 'alias aws_na' => ['aws_na'], + 'upper NA' => ['NA'], + 'upper US' => ['US'], + ]; + } + + // ------------------------------------------------------------------------- + // All seven regions – contentDelivery spot-checks + // ------------------------------------------------------------------------- + + /** + * @dataProvider regionContentDeliveryProvider + */ + public function testContentDeliveryUrlByRegion(string $region, string $expected): void + { + $url = Endpoint::getContentstackEndpoint($region, 'contentDelivery'); + $this->assertSame($expected, $url); + } + + public function regionContentDeliveryProvider(): array + { + return [ + 'na' => ['na', 'https://cdn.contentstack.io'], + 'eu' => ['eu', 'https://eu-cdn.contentstack.com'], + 'au' => ['au', 'https://au-cdn.contentstack.com'], + 'azure-na' => ['azure-na', 'https://azure-na-cdn.contentstack.com'], + 'azure-eu' => ['azure-eu', 'https://azure-eu-cdn.contentstack.com'], + 'gcp-na' => ['gcp-na', 'https://gcp-na-cdn.contentstack.com'], + 'gcp-eu' => ['gcp-eu', 'https://gcp-eu-cdn.contentstack.com'], + ]; + } + + /** + * @dataProvider regionContentManagementProvider + */ + public function testContentManagementUrlByRegion(string $region, string $expected): void + { + $url = Endpoint::getContentstackEndpoint($region, 'contentManagement'); + $this->assertSame($expected, $url); + } + + public function regionContentManagementProvider(): array + { + return [ + 'na' => ['na', 'https://api.contentstack.io'], + 'eu' => ['eu', 'https://eu-api.contentstack.com'], + 'au' => ['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'], + ]; + } + + // ------------------------------------------------------------------------- + // All service keys present for a region + // ------------------------------------------------------------------------- + + public function testAllServiceKeysPresent(): void + { + $expected = [ + 'application', 'contentDelivery', 'contentManagement', 'auth', + 'graphqlDelivery', 'preview', 'graphqlPreview', 'images', 'assets', + 'automate', 'launch', 'developerHub', 'brandKit', 'genAI', + 'personalizeManagement', 'personalizeEdge', 'composableStudio', + ]; + $endpoints = Endpoint::getContentstackEndpoint('eu'); + foreach ($expected as $key) { + $this->assertArrayHasKey($key, $endpoints, "Missing service key: {$key}"); + } + } + + // ------------------------------------------------------------------------- + // omitHttps flag + // ------------------------------------------------------------------------- + + public function testOmitHttpsStripsSchemeFromSingleService(): void + { + $url = Endpoint::getContentstackEndpoint('eu', 'contentDelivery', true); + $this->assertSame('eu-cdn.contentstack.com', $url); + } + + public function testOmitHttpsStripsSchemeFromAllServices(): void + { + $endpoints = Endpoint::getContentstackEndpoint('na', '', true); + $this->assertIsArray($endpoints); + foreach ($endpoints as $key => $url) { + $this->assertStringNotContainsString('https://', $url, "Service {$key} still has https://"); + $this->assertStringNotContainsString('http://', $url, "Service {$key} still has http://"); + } + } + + public function testOmitHttpsFalseRetainsScheme(): void + { + $url = Endpoint::getContentstackEndpoint('na', 'contentManagement', false); + $this->assertStringStartsWith('https://', $url); + } + + // ------------------------------------------------------------------------- + // Case-insensitive alias matching + // ------------------------------------------------------------------------- + + public function testUppercaseAliasResolves(): void + { + $url = Endpoint::getContentstackEndpoint('AWS-NA', 'contentDelivery'); + $this->assertSame('https://cdn.contentstack.io', $url); + } + + public function testUnderscoreAliasResolves(): void + { + $url = Endpoint::getContentstackEndpoint('azure_na', 'contentDelivery'); + $this->assertSame('https://azure-na-cdn.contentstack.com', $url); + } + + public function testGcpUnderscoreAliasResolves(): void + { + $url = Endpoint::getContentstackEndpoint('gcp_eu', 'contentManagement'); + $this->assertSame('https://gcp-eu-api.contentstack.com', $url); + } + + // ------------------------------------------------------------------------- + // Return-all-endpoints (no service) + // ------------------------------------------------------------------------- + + public function testNoServiceReturnsArray(): void + { + $result = Endpoint::getContentstackEndpoint('au'); + $this->assertIsArray($result); + $this->assertGreaterThan(1, count($result)); + } + + public function testNoServiceContainsCorrectUrls(): void + { + $endpoints = Endpoint::getContentstackEndpoint('au'); + $this->assertSame('https://au-cdn.contentstack.com', $endpoints['contentDelivery']); + $this->assertSame('https://au-api.contentstack.com', $endpoints['contentManagement']); + } + + // ------------------------------------------------------------------------- + // Error cases + // ------------------------------------------------------------------------- + + public function testEmptyRegionThrowsInvalidArgument(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Empty region provided'); + Endpoint::getContentstackEndpoint(''); + } + + public function testUnknownRegionThrowsInvalidArgument(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid region: invalid-region'); + Endpoint::getContentstackEndpoint('invalid-region'); + } + + public function testUnknownServiceThrowsInvalidArgument(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Service "unknownService" not found'); + Endpoint::getContentstackEndpoint('na', 'unknownService'); + } + + // ------------------------------------------------------------------------- + // Utils::getContentstackEndpoint() proxy + // ------------------------------------------------------------------------- + + public function testUtilsProxyReturnsSameResultAsEndpointClass(): void + { + $viaEndpoint = Endpoint::getContentstackEndpoint('eu', 'contentDelivery'); + $viaUtils = Utils::getContentstackEndpoint('eu', 'contentDelivery'); + $this->assertSame($viaEndpoint, $viaUtils); + } + + public function testUtilsProxyDefaultRegion(): void + { + $url = Utils::getContentstackEndpoint('us', 'contentManagement'); + $this->assertSame('https://api.contentstack.io', $url); + } + + public function testUtilsProxyOmitHttps(): void + { + $url = Utils::getContentstackEndpoint('gcp-na', 'contentDelivery', true); + $this->assertSame('gcp-na-cdn.contentstack.com', $url); + } + + public function testUtilsProxyAllEndpoints(): void + { + $endpoints = Utils::getContentstackEndpoint('azure-eu'); + $this->assertIsArray($endpoints); + $this->assertArrayHasKey('contentDelivery', $endpoints); + } +}