|
| 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