Skip to content

Commit 4fd8b01

Browse files
Added support for endpoint integration
1 parent e0803fe commit 4fd8b01

9 files changed

Lines changed: 1046 additions & 234 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ tmp/
1616
test/result.json
1717
stdout
1818
build
19-
cache
19+
cache
20+
src/assets/regions.json

composer.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@
3030
"code-lts/doctum": "^5.3"
3131
},
3232
"scripts": {
33+
"post-install-cmd": ["@php scripts/download-regions.php"],
34+
"post-update-cmd": ["@php scripts/download-regions.php"],
3335
"generate:docs": "vendor/bin/doctum.php update ./config.php",
34-
"test": "vendor/bin/phpunit"
36+
"test": "vendor/bin/phpunit",
37+
"refresh-regions": "@php scripts/download-regions.php"
3538
},
3639
"require": {
3740
"php" : ">=5.5.0",

composer.lock

Lines changed: 345 additions & 219 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/download-regions.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
/**
4+
* Downloads the Contentstack regions registry from the official source and
5+
* saves it to src/assets/regions.json.
6+
*
7+
* Invoked automatically by Composer on post-install-cmd and post-update-cmd,
8+
* and manually via: composer refresh-regions
9+
*
10+
* Uses the PHP curl extension when available, falls back to file_get_contents.
11+
*/
12+
13+
$url = 'https://artifacts.contentstack.com/regions.json';
14+
$dest = dirname(__DIR__) . '/src/assets/regions.json';
15+
$dir = dirname($dest);
16+
17+
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
18+
fwrite(STDERR, "contentstack/contentstack: Failed to create directory {$dir}\n");
19+
exit(1);
20+
}
21+
22+
$data = null;
23+
24+
// --- Attempt 1: PHP curl extension (preferred, respects SSL certs) ----------
25+
if (extension_loaded('curl')) {
26+
$ch = curl_init($url);
27+
curl_setopt_array($ch, [
28+
CURLOPT_RETURNTRANSFER => true,
29+
CURLOPT_FOLLOWLOCATION => true,
30+
CURLOPT_TIMEOUT => 30,
31+
CURLOPT_SSL_VERIFYPEER => true,
32+
]);
33+
$response = curl_exec($ch);
34+
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
35+
$curlError = curl_error($ch);
36+
curl_close($ch);
37+
38+
if ($response !== false && $httpCode === 200) {
39+
$data = $response;
40+
} elseif ($curlError) {
41+
fwrite(STDERR, "contentstack/contentstack: curl error: {$curlError}\n");
42+
}
43+
}
44+
45+
// --- Attempt 2: file_get_contents fallback ----------------------------------
46+
if ($data === null) {
47+
$ctx = stream_context_create([
48+
'http' => [
49+
'timeout' => 30,
50+
'ignore_errors' => false,
51+
],
52+
'ssl' => [
53+
'verify_peer' => true,
54+
'verify_peer_name' => true,
55+
],
56+
]);
57+
$data = @file_get_contents($url, false, $ctx);
58+
}
59+
60+
// --- Validate and write -----------------------------------------------------
61+
if ($data === false || $data === null) {
62+
fwrite(STDERR, "contentstack/contentstack: Warning — could not download regions.json. " .
63+
"The SDK will attempt to download it at runtime on first use.\n");
64+
exit(0); // non-fatal: runtime fallback in Endpoint::loadRegions() handles it
65+
}
66+
67+
$decoded = json_decode($data, true);
68+
if (!is_array($decoded) || !isset($decoded['regions']) || !is_array($decoded['regions'])) {
69+
fwrite(STDERR, "contentstack/contentstack: Warning — downloaded data is not a valid regions.json.\n");
70+
exit(0);
71+
}
72+
73+
if (file_put_contents($dest, $data) === false) {
74+
fwrite(STDERR, "contentstack/contentstack: Warning — could not write regions.json to {$dest}.\n");
75+
exit(0);
76+
}
77+
78+
$regionCount = count($decoded['regions']);
79+
echo "contentstack/contentstack: regions.json downloaded ({$regionCount} regions).\n";

src/Contentstack.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
*/
1515
namespace Contentstack;
1616

17+
use Contentstack\Endpoint;
1718
use Contentstack\Stack\Stack;
1819
use Contentstack\Utils\Utils;
1920
use Contentstack\Utils\Model\Option;
@@ -53,7 +54,25 @@ public static function Stack($api_key = '',
5354
return new Stack($api_key, $access_token, $environment, $config);
5455
}
5556

56-
public static function renderContent(string $content, Option $option): string
57+
/**
58+
* Resolve a Contentstack service endpoint URL for a given region.
59+
*
60+
* @param string $region Region ID or alias (e.g. 'us', 'eu', 'azure-na', 'gcp-eu').
61+
* @param string $service Optional service key (e.g. 'contentDelivery', 'contentManagement').
62+
* When empty, all endpoints for the region are returned as an array.
63+
* @param bool $omitHttps When true, strips the 'https://' prefix from returned URL(s).
64+
*
65+
* @return string|array<string,string>
66+
*/
67+
public static function getContentstackEndpoint(
68+
string $region = 'us',
69+
string $service = '',
70+
bool $omitHttps = false
71+
) {
72+
return Endpoint::getContentstackEndpoint($region, $service, $omitHttps);
73+
}
74+
75+
public static function renderContent(string $content, Option $option): string
5776
{
5877
return Utils::renderContent($content, $option);
5978
}

src/ContentstackRegion.php

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@
2727
* */
2828
class ContentstackRegion
2929
{
30-
const EU= "eu";
31-
const US= "us";
32-
const AZURE_NA= "azure-na";
33-
const AZURE_EU= "azure-eu";
34-
const GCP_NA= "gcp-na";
30+
const US = "us";
31+
const EU = "eu";
32+
const AU = "au";
33+
const AZURE_NA = "azure-na";
34+
const AZURE_EU = "azure-eu";
35+
const GCP_NA = "gcp-na";
36+
const GCP_EU = "gcp-eu";
3537
}

src/Endpoint.php

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
<?php
2+
/**
3+
* Endpoint — Contentstack region-to-URL resolver.
4+
*
5+
* PHP version 7.2+
6+
*
7+
* @category PHP
8+
* @package Contentstack
9+
* @copyright 2012-2024 Contentstack. All Rights Reserved
10+
* @license https://github.com/contentstack/contentstack-php/blob/master/LICENSE.txt MIT Licence
11+
* @link https://www.contentstack.com/docs/developers/php/
12+
*/
13+
namespace Contentstack;
14+
15+
/**
16+
* Resolves Contentstack service endpoint URLs for any supported region.
17+
*
18+
* Region data is loaded from src/assets/regions.json (bundled) and cached
19+
* in-memory for the lifetime of the PHP process. When the bundled file is
20+
* absent the class attempts a live download from the Contentstack CDN so the
21+
* SDK continues to work even when the file was not created during installation.
22+
*/
23+
class Endpoint
24+
{
25+
/** @var array<string,mixed>|null */
26+
private static $regionsData = null;
27+
28+
/** @var string */
29+
const REGIONS_URL = 'https://artifacts.contentstack.com/regions.json';
30+
31+
/**
32+
* Resolve a Contentstack service endpoint URL for a given region.
33+
*
34+
* @param string $region Region ID or alias (e.g. 'us', 'eu', 'azure-na', 'gcp-eu').
35+
* Defaults to 'us' (AWS North America).
36+
* @param string $service Optional service key (e.g. 'contentDelivery',
37+
* 'contentManagement', 'auth', 'graphqlDelivery').
38+
* When empty, all endpoints for the region are returned.
39+
* @param bool $omitHttps When true, strips the 'https://' prefix from every URL.
40+
*
41+
* @return string|array<string,string> Single URL string when $service is provided,
42+
* associative array of all service URLs otherwise.
43+
*
44+
* @throws \InvalidArgumentException When region is empty, unknown, or service is not found.
45+
* @throws \RuntimeException When regions.json cannot be read or parsed.
46+
*/
47+
public static function getContentstackEndpoint(
48+
string $region = 'us',
49+
string $service = '',
50+
bool $omitHttps = false
51+
) {
52+
if ($region === '') {
53+
throw new \InvalidArgumentException(
54+
'Empty region provided. Please put valid region.'
55+
);
56+
}
57+
58+
$data = self::loadRegions();
59+
$normalized = strtolower(trim($region));
60+
$regionRow = self::findRegionByIdOrAlias($data['regions'], $normalized);
61+
62+
if ($regionRow === null) {
63+
throw new \InvalidArgumentException("Invalid region: {$region}");
64+
}
65+
66+
if ($service !== '') {
67+
if (!array_key_exists($service, $regionRow['endpoints'])) {
68+
throw new \InvalidArgumentException(
69+
"Service \"{$service}\" not found for region \"{$regionRow['id']}\""
70+
);
71+
}
72+
$url = $regionRow['endpoints'][$service];
73+
return $omitHttps ? self::stripHttps($url) : $url;
74+
}
75+
76+
$endpoints = $regionRow['endpoints'];
77+
return $omitHttps ? self::stripHttpsFromMap($endpoints) : $endpoints;
78+
}
79+
80+
/**
81+
* Load and cache regions.json.
82+
*
83+
* Resolution order:
84+
* 1. In-memory static cache (zero I/O after first call)
85+
* 2. src/assets/regions.json on disk (written by composer install script)
86+
* 3. Live download from artifacts.contentstack.com (fallback)
87+
*
88+
* @return array<string,mixed>
89+
*/
90+
private static function loadRegions(): array
91+
{
92+
if (self::$regionsData !== null) {
93+
return self::$regionsData;
94+
}
95+
96+
$path = __DIR__ . '/assets/regions.json';
97+
98+
if (!file_exists($path)) {
99+
self::downloadAndSave($path);
100+
}
101+
102+
if (!file_exists($path)) {
103+
throw new \RuntimeException(
104+
'contentstack/contentstack: regions.json not found and could not be downloaded. ' .
105+
'Run "composer install" or "composer refresh-regions" and ensure network access.'
106+
);
107+
}
108+
109+
$raw = file_get_contents($path);
110+
if ($raw === false) {
111+
throw new \RuntimeException(
112+
'contentstack/contentstack: Could not read regions.json.'
113+
);
114+
}
115+
116+
$decoded = json_decode($raw, true);
117+
if (!is_array($decoded) || !isset($decoded['regions'])) {
118+
throw new \RuntimeException(
119+
'contentstack/contentstack: regions.json is corrupt. ' .
120+
'Run "composer refresh-regions" to re-download it.'
121+
);
122+
}
123+
124+
self::$regionsData = $decoded;
125+
return self::$regionsData;
126+
}
127+
128+
/**
129+
* Download regions.json from the Contentstack CDN and save to disk.
130+
* Tries the PHP curl extension first, falls back to file_get_contents.
131+
* Silent on failure — the caller decides whether a missing file is fatal.
132+
*
133+
* @param string $dest Absolute path to write the file to.
134+
*/
135+
private static function downloadAndSave(string $dest): void
136+
{
137+
$dir = dirname($dest);
138+
if (!is_dir($dir)) {
139+
mkdir($dir, 0755, true);
140+
}
141+
142+
$data = null;
143+
144+
if (extension_loaded('curl')) {
145+
$ch = curl_init(self::REGIONS_URL);
146+
curl_setopt_array($ch, [
147+
CURLOPT_RETURNTRANSFER => true,
148+
CURLOPT_FOLLOWLOCATION => true,
149+
CURLOPT_TIMEOUT => 30,
150+
CURLOPT_SSL_VERIFYPEER => true,
151+
]);
152+
$response = curl_exec($ch);
153+
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
154+
curl_close($ch);
155+
if ($response !== false && $httpCode === 200) {
156+
$data = $response;
157+
}
158+
}
159+
160+
if ($data === null) {
161+
$ctx = stream_context_create(['http' => ['timeout' => 30]]);
162+
$data = @file_get_contents(self::REGIONS_URL, false, $ctx);
163+
}
164+
165+
if (!$data) {
166+
return;
167+
}
168+
169+
$decoded = json_decode($data, true);
170+
if (is_array($decoded) && isset($decoded['regions'])) {
171+
file_put_contents($dest, $data);
172+
}
173+
}
174+
175+
/**
176+
* Find a region entry by its id or any alias (case-insensitive).
177+
*
178+
* @param array<int,array<string,mixed>> $regions
179+
* @param string $input Already lowercased input.
180+
* @return array<string,mixed>|null
181+
*/
182+
private static function findRegionByIdOrAlias(array $regions, string $input): ?array
183+
{
184+
foreach ($regions as $row) {
185+
if ($row['id'] === $input) {
186+
return $row;
187+
}
188+
}
189+
foreach ($regions as $row) {
190+
foreach ($row['alias'] as $alias) {
191+
if (strtolower($alias) === $input) {
192+
return $row;
193+
}
194+
}
195+
}
196+
return null;
197+
}
198+
199+
/**
200+
* Strip the https:// (or http://) scheme from a URL string.
201+
*/
202+
private static function stripHttps(string $url): string
203+
{
204+
return (string) preg_replace('/^https?:\/\//', '', $url);
205+
}
206+
207+
/**
208+
* Strip https:// from every value in an endpoint map.
209+
*
210+
* @param array<string,string> $endpoints
211+
* @return array<string,string>
212+
*/
213+
private static function stripHttpsFromMap(array $endpoints): array
214+
{
215+
$result = [];
216+
foreach ($endpoints as $key => $url) {
217+
$result[$key] = self::stripHttps($url);
218+
}
219+
return $result;
220+
}
221+
222+
/**
223+
* Reset the internal region cache (intended for testing only).
224+
*/
225+
public static function resetCache(): void
226+
{
227+
self::$regionsData = null;
228+
}
229+
}

0 commit comments

Comments
 (0)