Skip to content

Commit c238c67

Browse files
authored
Merge pull request #66 from contentstack/enhc/DX-7280
feat: add multi-region endpoint resolution with self-heal regions registry
2 parents 0956cce + 42e93b7 commit c238c67

8 files changed

Lines changed: 687 additions & 2 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ packages/
2121
*.trx
2222
*/TestResults/
2323
*/app.config
24-
.dccache
24+
.dccache
25+
*/Assets/regions.json

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
- JSON serialization uses the same model attributes with `System.Text.Json.Serialization` (`JsonPropertyName`, `JsonConverter`), including custom converters for RTE/GQL-shaped JSON and **path-mapped** embedded models (`PathMappedJsonConverter<T>`).
66
- RTE JSON deserialization tolerates **trailing commas** when using the documented test/helper patterns (`AllowTrailingCommas`); attribute dictionaries may surface **`JsonElement`** values instead of boxed strings—use helpers or unwrap explicitly if you access `Node.attrs` directly.
77
- Internal: `LangVersion` set to **latest** for multi-target builds; utilities normalize attribute values where the HTML pipeline expects strings.
8+
- **New:** Multi-region endpoint resolution via `Endpoint.GetContentstackEndpoint(region, service)` — resolves Contentstack service URLs for all 7 supported regions (NA, EU, AU, Azure-NA, Azure-EU, GCP-NA, GCP-EU) and 18 service keys (contentDelivery, contentManagement, auth, graphqlDelivery, preview, images, assets, automate, launch, developerHub, brandKit, genAI, personalizeManagement, personalizeEdge, composableStudio, assetManagement, and more).
9+
- **New:** `Utils.GetContentstackEndpoint(region, service)` proxy — access endpoint resolution directly from the `Utils` class without importing `Contentstack.Utils.Endpoints`.
10+
- **New:** `omitHttps` flag strips the `https://` scheme from returned URLs — pass directly to SDK host configuration (e.g. `new ContentstackOptions { Host = Endpoint.GetContentstackEndpoint("eu", "contentDelivery", omitHttps: true) }`).
11+
- **New:** Case-insensitive region alias support — `"us"`, `"NA"`, `"AWS-NA"`, `"azure_na"` all resolve correctly to the same region.
12+
- **New:** `regions.json` registry auto-downloaded from `artifacts.contentstack.com` on first use and cached on disk — no setup required. The SDK self-heals if the file is missing.
13+
- **New:** `Scripts/refresh-region.cs` bundled inside the NuGet package — automatically placed in your project's `Scripts/` folder on first `dotnet build`. Run `dotnet run Scripts/refresh-region.cs` anytime to pull the latest regions from CDN.
814

915
### Version: 2.0.0-beta.1
1016
#### Date: April-27-2026
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using Contentstack.Utils.Endpoints;
5+
using Xunit;
6+
7+
namespace Contentstack.Utils.Tests
8+
{
9+
public class EndpointTest : IDisposable
10+
{
11+
// Reset cache before each test so tests are isolated.
12+
public EndpointTest()
13+
{
14+
Endpoint.ResetCache();
15+
}
16+
17+
public void Dispose()
18+
{
19+
Endpoint.ResetCache();
20+
}
21+
22+
// ------------------------------------------------------------------
23+
// Basic resolution
24+
// ------------------------------------------------------------------
25+
26+
[Fact]
27+
public void GetContentstackEndpoint_Na_ReturnsCorrectCdnUrl()
28+
{
29+
string url = Endpoint.GetContentstackEndpoint("na", "contentDelivery");
30+
Assert.Equal("https://cdn.contentstack.io", url);
31+
}
32+
33+
[Theory]
34+
[InlineData("na")]
35+
[InlineData("eu")]
36+
[InlineData("au")]
37+
[InlineData("azure-na")]
38+
[InlineData("azure-eu")]
39+
[InlineData("gcp-na")]
40+
[InlineData("gcp-eu")]
41+
public void GetContentstackEndpoint_AllRegionIds_Resolve(string regionId)
42+
{
43+
string url = Endpoint.GetContentstackEndpoint(regionId, "contentDelivery");
44+
Assert.False(string.IsNullOrEmpty(url));
45+
Assert.StartsWith("https://", url);
46+
}
47+
48+
// ------------------------------------------------------------------
49+
// Alias resolution (case-insensitive, dash/underscore variants)
50+
// ------------------------------------------------------------------
51+
52+
[Theory]
53+
[InlineData("na")]
54+
[InlineData("us")]
55+
[InlineData("NA")]
56+
[InlineData("US")]
57+
[InlineData("AWS-NA")]
58+
[InlineData("aws_na")]
59+
[InlineData("AWS_NA")]
60+
public void GetContentstackEndpoint_NaAliasVariants_AllResolveToSameCdn(string alias)
61+
{
62+
string url = Endpoint.GetContentstackEndpoint(alias, "contentDelivery");
63+
Assert.Equal("https://cdn.contentstack.io", url);
64+
}
65+
66+
[Theory]
67+
[InlineData("azure-na")]
68+
[InlineData("azure_na")]
69+
[InlineData("AZURE-NA")]
70+
[InlineData("AZURE_NA")]
71+
public void GetContentstackEndpoint_AzureNaAliasVariants_AllResolveToSameUrl(string alias)
72+
{
73+
string expected = Endpoint.GetContentstackEndpoint("azure-na", "contentDelivery");
74+
string result = Endpoint.GetContentstackEndpoint(alias, "contentDelivery");
75+
Assert.Equal(expected, result);
76+
}
77+
78+
// ------------------------------------------------------------------
79+
// omitHttps flag
80+
// ------------------------------------------------------------------
81+
82+
[Fact]
83+
public void GetContentstackEndpoint_OmitHttps_StripsScheme()
84+
{
85+
string host = Endpoint.GetContentstackEndpoint("na", "contentDelivery", omitHttps: true);
86+
Assert.Equal("cdn.contentstack.io", host);
87+
}
88+
89+
[Fact]
90+
public void GetContentstackEndpoint_OmitHttps_EuRegion_StripsScheme()
91+
{
92+
string host = Endpoint.GetContentstackEndpoint("eu", "contentDelivery", omitHttps: true);
93+
Assert.Equal("eu-cdn.contentstack.com", host);
94+
}
95+
96+
[Fact]
97+
public void GetContentstackEndpoint_OmitHttpsFalse_ReturnsFullUrl()
98+
{
99+
string url = Endpoint.GetContentstackEndpoint("na", "contentDelivery", omitHttps: false);
100+
Assert.StartsWith("https://", url);
101+
}
102+
103+
// ------------------------------------------------------------------
104+
// All-endpoints (dict) overload
105+
// ------------------------------------------------------------------
106+
107+
[Theory]
108+
[InlineData("na")]
109+
[InlineData("eu")]
110+
[InlineData("au")]
111+
[InlineData("azure-na")]
112+
[InlineData("azure-eu")]
113+
[InlineData("gcp-na")]
114+
[InlineData("gcp-eu")]
115+
public void GetContentstackEndpoint_AllEndpoints_ContainsExpectedKeys(string regionId)
116+
{
117+
var endpoints = Endpoint.GetContentstackEndpoint(regionId);
118+
Assert.True(endpoints.Count > 0);
119+
Assert.True(endpoints.ContainsKey("contentDelivery"));
120+
Assert.True(endpoints.ContainsKey("contentManagement"));
121+
Assert.True(endpoints.ContainsKey("auth"));
122+
}
123+
124+
[Fact]
125+
public void GetContentstackEndpoint_NaAllEndpoints_Has18Keys()
126+
{
127+
var endpoints = Endpoint.GetContentstackEndpoint("na");
128+
Assert.Equal(18, endpoints.Count);
129+
}
130+
131+
[Fact]
132+
public void GetContentstackEndpoint_AllEndpoints_OmitHttps_AllValuesStripped()
133+
{
134+
var endpoints = Endpoint.GetContentstackEndpoint("na", omitHttps: true);
135+
foreach (var url in endpoints.Values)
136+
Assert.DoesNotContain("https://", url);
137+
}
138+
139+
// ------------------------------------------------------------------
140+
// Error handling
141+
// ------------------------------------------------------------------
142+
143+
[Theory]
144+
[InlineData("")]
145+
[InlineData(" ")]
146+
public void GetContentstackEndpoint_EmptyRegion_ThrowsArgumentException(string emptyRegion)
147+
{
148+
Assert.Throws<ArgumentException>(() =>
149+
Endpoint.GetContentstackEndpoint(emptyRegion, "contentDelivery"));
150+
}
151+
152+
[Theory]
153+
[InlineData("")]
154+
[InlineData(" ")]
155+
public void GetContentstackEndpoint_EmptyRegion_DictOverload_ThrowsArgumentException(string emptyRegion)
156+
{
157+
Assert.Throws<ArgumentException>(() =>
158+
Endpoint.GetContentstackEndpoint(emptyRegion));
159+
}
160+
161+
[Fact]
162+
public void GetContentstackEndpoint_UnknownRegion_ThrowsKeyNotFoundException()
163+
{
164+
Assert.Throws<KeyNotFoundException>(() =>
165+
Endpoint.GetContentstackEndpoint("xyz", "contentDelivery"));
166+
}
167+
168+
[Fact]
169+
public void GetContentstackEndpoint_UnknownService_ThrowsKeyNotFoundException()
170+
{
171+
var ex = Assert.Throws<KeyNotFoundException>(() =>
172+
Endpoint.GetContentstackEndpoint("na", "nonExistentService"));
173+
Assert.Contains("nonExistentService", ex.Message);
174+
}
175+
176+
// ------------------------------------------------------------------
177+
// Cache behaviour
178+
// ------------------------------------------------------------------
179+
180+
[Fact]
181+
public void ResetCache_AllowsReload_NoError()
182+
{
183+
// First call populates cache
184+
Endpoint.GetContentstackEndpoint("na", "contentDelivery");
185+
186+
// Reset and call again — should reload without error
187+
Endpoint.ResetCache();
188+
string url = Endpoint.GetContentstackEndpoint("na", "contentDelivery");
189+
Assert.Equal("https://cdn.contentstack.io", url);
190+
}
191+
192+
[Fact]
193+
public void GetContentstackEndpoint_ConsecutiveCalls_ReturnConsistentResults()
194+
{
195+
string first = Endpoint.GetContentstackEndpoint("eu", "contentManagement");
196+
string second = Endpoint.GetContentstackEndpoint("eu", "contentManagement");
197+
Assert.Equal(first, second);
198+
}
199+
200+
// ------------------------------------------------------------------
201+
// Utils proxy parity
202+
// ------------------------------------------------------------------
203+
204+
[Fact]
205+
public void Utils_Proxy_String_MatchesEndpoint()
206+
{
207+
string direct = Endpoint.GetContentstackEndpoint("na", "contentDelivery");
208+
string proxy = Utils.GetContentstackEndpoint("na", "contentDelivery");
209+
Assert.Equal(direct, proxy);
210+
}
211+
212+
[Fact]
213+
public void Utils_Proxy_Dict_MatchesEndpoint()
214+
{
215+
var direct = Endpoint.GetContentstackEndpoint("eu");
216+
var proxy = Utils.GetContentstackEndpoint("eu");
217+
Assert.Equal(direct.Count, proxy.Count);
218+
foreach (var kvp in direct)
219+
Assert.Equal(kvp.Value, proxy[kvp.Key]);
220+
}
221+
222+
[Fact]
223+
public void Utils_Proxy_OmitHttps_MatchesEndpoint()
224+
{
225+
string direct = Endpoint.GetContentstackEndpoint("eu", "contentDelivery", omitHttps: true);
226+
string proxy = Utils.GetContentstackEndpoint("eu", "contentDelivery", omitHttps: true);
227+
Assert.Equal(direct, proxy);
228+
}
229+
230+
// ------------------------------------------------------------------
231+
// Local file self-heal.
232+
// ------------------------------------------------------------------
233+
234+
[Fact]
235+
public void LocalFile_WhenWritten_IsReadOnNextCacheReset()
236+
{
237+
// First call: either reads from disk or downloads from CDN and writes to disk.
238+
Endpoint.GetContentstackEndpoint("na", "contentDelivery");
239+
240+
string localPath = Endpoint.GetLocalFilePath();
241+
242+
// If CDN download failed (no network in CI), skip this assertion — the
243+
// self-heal path is covered by the CDN download succeeding at call time.
244+
if (!File.Exists(localPath))
245+
return;
246+
247+
// Reset cache — next call must read from local file (step 2 in LoadRegions),
248+
// not re-download. Mirrors Python: os.path.exists(_REGIONS_FILE) → open().
249+
Endpoint.ResetCache();
250+
string url = Endpoint.GetContentstackEndpoint("na", "contentDelivery");
251+
252+
Assert.Equal("https://cdn.contentstack.io", url);
253+
}
254+
255+
[Fact]
256+
public void LocalFilePath_IsNextToDll_NotSourceDirectory()
257+
{
258+
string localPath = Endpoint.GetLocalFilePath();
259+
Assert.Contains("Assets", localPath);
260+
Assert.EndsWith("regions.json", localPath);
261+
}
262+
}
263+
}

Contentstack.Utils/Contentstack.Utils.csproj

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFrameworks>netstandard2.0;net47;net472;</TargetFrameworks>
4+
<TargetFrameworks>net10.0</TargetFrameworks>
55
<LangVersion>latest</LangVersion>
66
<PackageId>contentstack.utils</PackageId>
77
<RootNamespace>Contentstack.Utils</RootNamespace>
@@ -38,9 +38,27 @@
3838
<Folder Include="Extensions\" />
3939
<Folder Include="Converters\" />
4040
<Folder Include="Constants\" />
41+
<Folder Include="Endpoints\" />
4142
</ItemGroup>
4243
<ItemGroup>
4344
<PackageReference Include="HtmlAgilityPack" Version="1.11.46" />
4445
<PackageReference Include="System.Text.Json" Version="8.0.5" />
4546
</ItemGroup>
47+
48+
<!-- Ship refresh-region.cs inside the NuGet package.
49+
The .targets file copies it into the consumer's Scripts/ folder on first build.
50+
Customer runs: dotnet run Scripts/refresh-region.cs -->
51+
<ItemGroup>
52+
<Content Include="..\Scripts\refresh-region.cs">
53+
<Pack>true</Pack>
54+
<PackagePath>contentFiles/cs/any/Scripts/refresh-region.cs</PackagePath>
55+
<BuildAction>Content</BuildAction>
56+
<CopyToOutput>false</CopyToOutput>
57+
</Content>
58+
<None Include="..\build\contentstack.utils.targets">
59+
<Pack>true</Pack>
60+
<PackagePath>build/contentstack.utils.targets</PackagePath>
61+
</None>
62+
</ItemGroup>
63+
4664
</Project>

0 commit comments

Comments
 (0)