Skip to content

Commit 9706449

Browse files
committed
feat: dynamic endpoint resolution via CDN-backed regions registry
- Added Endpoint class — resolves service URLs from regions.json with 3-tier cache (memory → disk → CDN) - Added ContentstackRegionMap — bridges ContentstackRegion enum to registry region IDs - Added GCP_EU to ContentstackRegion enum - Replaced hardcoded regionCode() and HostURL in Config.BaseUrl with Endpoint lookup - Replaced refresh-region.cs with refresh-region.py to prevent MSBuild from compiling the script as source - Added build/contentstack.csharp.targets to auto-deliver refresh-region.py on first consumer build - Added Assets/regions.json to .gitignore - Added EndpointTest.cs
1 parent 20f46c8 commit 9706449

9 files changed

Lines changed: 745 additions & 40 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,6 @@ packages/
6767
*.sln.docstates
6868

6969
# Python
70-
Scripts/venv/
70+
Scripts/venv/
71+
# Cached region registry — regenerated at runtime from CDN
72+
*/Assets/regions.json

CHANGELOG.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
1-
### Version: 3.0.0
2-
#### Date: Jun-15-2026
1+
### Version: 3.0.0-beta.2
2+
#### Date: Jun-22-2026
33

44
##### Feat:
5-
- Migrated all internal JSON handling to `System.Text.Json`
6-
- Added `ApiErrorBodyParser` for consistent API error envelope parsing
7-
- Added `JsonNodeConversion` and `JsonObjectMerge` utilities to replace Newtonsoft equivalents
8-
- Added `ContentstackJsonDefaults` — shared `JsonSerializerOptions` used across all custom converters
5+
- Added `Endpoint` class for dynamic region-to-URL resolution via CDN-backed `regions.json`
6+
- Added `ContentstackRegionMap` to map `ContentstackRegion` enum to registry region IDs
7+
- Added `GCP_EU` region support
98

109
##### Enh:
11-
- Replaced `Console.WriteLine` with `Debug.WriteLine` in `ContentstackConvert` to suppress parse warnings from application stdout
10+
- `Config.BaseUrl` now resolves hosts from the regions registry; removed hardcoded `regionCode()` and `HostURL`
1211

1312
##### Chore:
14-
- Updated .NET version in SCA scan CI from `7.0.x` to `10.0.x`
13+
- Replaced `refresh-region.cs` with `refresh-region.py` — avoids MSBuild compiling the script as source
14+
- Added `build/contentstack.csharp.targets` to auto-deliver `refresh-region.py` to consumer projects on first build
15+
- Added `Assets/regions.json` to `.gitignore`
16+
- Added `EndpointTest.cs`
1517

1618
---
1719

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using Contentstack.Core.Endpoints;
5+
using Xunit;
6+
7+
namespace Contentstack.Core.Tests
8+
{
9+
/// <summary>
10+
/// Tests for <see cref="Endpoint"/> — dynamic CDN-backed region resolution.
11+
/// Mirrors the coverage from contentstack-utils-dotnet PR #66 EndpointTest.cs,
12+
/// adapted for the Delivery SDK (contentDelivery as primary service key).
13+
/// </summary>
14+
public class EndpointTest : IDisposable
15+
{
16+
public EndpointTest()
17+
{
18+
// Each test starts with a clean cache so CDN/disk state doesn't bleed across tests.
19+
Endpoint.ResetCache();
20+
}
21+
22+
public void Dispose()
23+
{
24+
Endpoint.ResetCache();
25+
}
26+
27+
// ── Basic resolution ──────────────────────────────────────────────────
28+
29+
[Fact]
30+
public void GetContentstackEndpoint_Na_ReturnsCorrectCdnUrl()
31+
{
32+
string url = Endpoint.GetContentstackEndpoint("na", "contentDelivery");
33+
Assert.Equal("https://cdn.contentstack.io", url);
34+
}
35+
36+
[Fact]
37+
public void GetContentstackEndpoint_Eu_ReturnsCorrectCdnUrl()
38+
{
39+
string url = Endpoint.GetContentstackEndpoint("eu", "contentDelivery");
40+
Assert.Equal("https://eu-cdn.contentstack.com", url);
41+
}
42+
43+
[Fact]
44+
public void GetContentstackEndpoint_Au_ReturnsCorrectCdnUrl()
45+
{
46+
string url = Endpoint.GetContentstackEndpoint("au", "contentDelivery");
47+
Assert.Equal("https://au-cdn.contentstack.com", url);
48+
}
49+
50+
[Fact]
51+
public void GetContentstackEndpoint_AzureNa_ReturnsCorrectCdnUrl()
52+
{
53+
string url = Endpoint.GetContentstackEndpoint("azure-na", "contentDelivery");
54+
Assert.Equal("https://azure-na-cdn.contentstack.com", url);
55+
}
56+
57+
[Fact]
58+
public void GetContentstackEndpoint_AzureEu_ReturnsCorrectCdnUrl()
59+
{
60+
string url = Endpoint.GetContentstackEndpoint("azure-eu", "contentDelivery");
61+
Assert.Equal("https://azure-eu-cdn.contentstack.com", url);
62+
}
63+
64+
[Fact]
65+
public void GetContentstackEndpoint_GcpNa_ReturnsCorrectCdnUrl()
66+
{
67+
string url = Endpoint.GetContentstackEndpoint("gcp-na", "contentDelivery");
68+
Assert.Equal("https://gcp-na-cdn.contentstack.com", url);
69+
}
70+
71+
[Fact]
72+
public void GetContentstackEndpoint_GcpEu_ReturnsCorrectCdnUrl()
73+
{
74+
string url = Endpoint.GetContentstackEndpoint("gcp-eu", "contentDelivery");
75+
Assert.Equal("https://gcp-eu-cdn.contentstack.com", url);
76+
}
77+
78+
// ── NA alias resolution ───────────────────────────────────────────────
79+
80+
[Theory]
81+
[InlineData("na")]
82+
[InlineData("us")]
83+
[InlineData("NA")]
84+
[InlineData("US")]
85+
[InlineData("AWS-NA")]
86+
[InlineData("aws_na")]
87+
[InlineData("AWS_NA")]
88+
public void GetContentstackEndpoint_NaAliasVariants_AllResolveToSameCdn(string alias)
89+
{
90+
string url = Endpoint.GetContentstackEndpoint(alias, "contentDelivery");
91+
Assert.Equal("https://cdn.contentstack.io", url);
92+
}
93+
94+
// ── omitHttps flag ────────────────────────────────────────────────────
95+
96+
[Fact]
97+
public void GetContentstackEndpoint_OmitHttps_ReturnsHostOnly()
98+
{
99+
string host = Endpoint.GetContentstackEndpoint("na", "contentDelivery", omitHttps: true);
100+
Assert.Equal("cdn.contentstack.io", host);
101+
}
102+
103+
[Fact]
104+
public void GetContentstackEndpoint_OmitHttps_Eu_ReturnsHostOnly()
105+
{
106+
string host = Endpoint.GetContentstackEndpoint("eu", "contentDelivery", omitHttps: true);
107+
Assert.Equal("eu-cdn.contentstack.com", host);
108+
}
109+
110+
[Fact]
111+
public void GetContentstackEndpoint_OmitHttpsFalse_ReturnsFullUrl()
112+
{
113+
string url = Endpoint.GetContentstackEndpoint("na", "contentDelivery", omitHttps: false);
114+
Assert.StartsWith("https://", url);
115+
}
116+
117+
// ── Management endpoint (also valid for Delivery SDK to know) ─────────
118+
119+
[Fact]
120+
public void GetContentstackEndpoint_Na_ContentManagement_ReturnsApiUrl()
121+
{
122+
string url = Endpoint.GetContentstackEndpoint("na", "contentManagement");
123+
Assert.Equal("https://api.contentstack.io", url);
124+
}
125+
126+
// ── Dictionary overload ───────────────────────────────────────────────
127+
128+
[Fact]
129+
public void GetContentstackEndpoint_DictionaryOverload_Na_ContainsContentDelivery()
130+
{
131+
Dictionary<string, string> all = Endpoint.GetContentstackEndpoint("na");
132+
Assert.True(all.ContainsKey("contentDelivery"));
133+
Assert.Equal("https://cdn.contentstack.io", all["contentDelivery"]);
134+
}
135+
136+
[Fact]
137+
public void GetContentstackEndpoint_DictionaryOverload_Na_HasExpectedKeyCount()
138+
{
139+
Dictionary<string, string> all = Endpoint.GetContentstackEndpoint("na");
140+
Assert.True(all.Count >= 18, $"Expected at least 18 service keys, got {all.Count}");
141+
}
142+
143+
[Fact]
144+
public void GetContentstackEndpoint_DictionaryOverload_OmitHttps_AllValuesLackScheme()
145+
{
146+
Dictionary<string, string> all = Endpoint.GetContentstackEndpoint("na", omitHttps: true);
147+
foreach (var kvp in all)
148+
Assert.DoesNotMatch(@"^https?://", kvp.Value);
149+
}
150+
151+
[Fact]
152+
public void GetContentstackEndpoint_DictionaryOverload_Eu_ContainsContentDelivery()
153+
{
154+
Dictionary<string, string> all = Endpoint.GetContentstackEndpoint("eu");
155+
Assert.True(all.ContainsKey("contentDelivery"));
156+
}
157+
158+
// ── Error handling ────────────────────────────────────────────────────
159+
160+
[Fact]
161+
public void GetContentstackEndpoint_EmptyRegion_ThrowsArgumentException()
162+
{
163+
Assert.Throws<ArgumentException>(() =>
164+
Endpoint.GetContentstackEndpoint("", "contentDelivery"));
165+
}
166+
167+
[Fact]
168+
public void GetContentstackEndpoint_WhitespaceRegion_ThrowsArgumentException()
169+
{
170+
Assert.Throws<ArgumentException>(() =>
171+
Endpoint.GetContentstackEndpoint(" ", "contentDelivery"));
172+
}
173+
174+
[Fact]
175+
public void GetContentstackEndpoint_UnknownRegion_ThrowsKeyNotFoundException()
176+
{
177+
Assert.Throws<KeyNotFoundException>(() =>
178+
Endpoint.GetContentstackEndpoint("invalid-region-xyz", "contentDelivery"));
179+
}
180+
181+
[Fact]
182+
public void GetContentstackEndpoint_UnknownService_ThrowsKeyNotFoundException()
183+
{
184+
Assert.Throws<KeyNotFoundException>(() =>
185+
Endpoint.GetContentstackEndpoint("na", "nonExistentService"));
186+
}
187+
188+
[Fact]
189+
public void GetContentstackEndpoint_DictionaryOverload_EmptyRegion_ThrowsArgumentException()
190+
{
191+
Assert.Throws<ArgumentException>(() =>
192+
Endpoint.GetContentstackEndpoint("", omitHttps: false));
193+
}
194+
195+
[Fact]
196+
public void GetContentstackEndpoint_DictionaryOverload_UnknownRegion_ThrowsKeyNotFoundException()
197+
{
198+
Assert.Throws<KeyNotFoundException>(() =>
199+
Endpoint.GetContentstackEndpoint("invalid-region-xyz"));
200+
}
201+
202+
// ── Cache consistency ─────────────────────────────────────────────────
203+
204+
[Fact]
205+
public void GetContentstackEndpoint_CalledTwice_ReturnsSameResult()
206+
{
207+
string url1 = Endpoint.GetContentstackEndpoint("na", "contentDelivery");
208+
string url2 = Endpoint.GetContentstackEndpoint("na", "contentDelivery");
209+
Assert.Equal(url1, url2);
210+
}
211+
212+
[Fact]
213+
public void ResetCache_ThenResolve_StillReturnsCorrectUrl()
214+
{
215+
// Warm the cache
216+
Endpoint.GetContentstackEndpoint("na", "contentDelivery");
217+
218+
// Reset and resolve again
219+
Endpoint.ResetCache();
220+
string url = Endpoint.GetContentstackEndpoint("na", "contentDelivery");
221+
Assert.Equal("https://cdn.contentstack.io", url);
222+
}
223+
224+
// ── Local file path ───────────────────────────────────────────────────
225+
226+
[Fact]
227+
public void GetLocalFilePath_ContainsAssetsAndRegionsJson()
228+
{
229+
string path = Endpoint.GetLocalFilePath();
230+
Assert.Contains("Assets", path);
231+
Assert.EndsWith("regions.json", path);
232+
}
233+
234+
[Fact]
235+
public void GetLocalFilePath_PathIsAbsolute()
236+
{
237+
string path = Endpoint.GetLocalFilePath();
238+
Assert.True(Path.IsPathRooted(path), $"Expected absolute path, got: {path}");
239+
}
240+
241+
// ── Local file self-heal ──────────────────────────────────────────────
242+
243+
[Fact]
244+
public void GetContentstackEndpoint_WritesLocalFile_AfterCdnDownload()
245+
{
246+
// Delete local file to force CDN download
247+
string localFile = Endpoint.GetLocalFilePath();
248+
if (File.Exists(localFile))
249+
File.Delete(localFile);
250+
251+
Endpoint.ResetCache();
252+
253+
// This call should trigger CDN download and write the file
254+
Endpoint.GetContentstackEndpoint("na", "contentDelivery");
255+
256+
Assert.True(File.Exists(localFile), $"Expected regions.json to be written to: {localFile}");
257+
}
258+
259+
// ── URL format validation ─────────────────────────────────────────────
260+
261+
[Theory]
262+
[InlineData("na")]
263+
[InlineData("eu")]
264+
[InlineData("au")]
265+
[InlineData("azure-na")]
266+
[InlineData("azure-eu")]
267+
[InlineData("gcp-na")]
268+
[InlineData("gcp-eu")]
269+
public void GetContentstackEndpoint_AllRegions_ContentDelivery_StartsWithHttps(string region)
270+
{
271+
string url = Endpoint.GetContentstackEndpoint(region, "contentDelivery");
272+
Assert.StartsWith("https://", url);
273+
}
274+
275+
[Theory]
276+
[InlineData("na")]
277+
[InlineData("eu")]
278+
[InlineData("au")]
279+
[InlineData("azure-na")]
280+
[InlineData("azure-eu")]
281+
[InlineData("gcp-na")]
282+
[InlineData("gcp-eu")]
283+
public void GetContentstackEndpoint_AllRegions_OmitHttps_DoesNotStartWithHttps(string region)
284+
{
285+
string host = Endpoint.GetContentstackEndpoint(region, "contentDelivery", omitHttps: true);
286+
Assert.DoesNotMatch(@"^https?://", host);
287+
}
288+
}
289+
}

Contentstack.Core/Configuration/Config.cs

Lines changed: 15 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
using System;
2-
using System.Linq;
3-
using System.Net;
1+
using System.Net;
2+
using Contentstack.Core.Endpoints;
43
using Contentstack.Core.Internals;
54

65
namespace Contentstack.Core.Configuration
@@ -35,8 +34,8 @@ public string Protocol {
3534
set { this._Protocol = value; }
3635
}
3736

38-
public string Host {
39-
get { return _Host ?? HostURL; }
37+
public string Host {
38+
get { return _Host ?? string.Empty; }
4039
set { this._Host = value; }
4140
}
4241

@@ -76,12 +75,17 @@ public string BaseUrl
7675
{
7776
get
7877
{
79-
string BaseURL = string.Format("{0}://{1}{2}/{3}",
80-
this.Protocol.Trim('/').Trim('\\'),
81-
regionCode(),
82-
this.Host.Trim('/').Trim('\\'),
83-
this.Version.Trim('/').Trim('\\'));
84-
return BaseURL;
78+
string protocol = this.Protocol.Trim('/').Trim('\\');
79+
string version = this.Version.Trim('/').Trim('\\');
80+
81+
// Custom host explicitly set — bypass CDN registry and use it directly.
82+
if (!string.IsNullOrEmpty(_Host))
83+
return string.Format("{0}://{1}/{2}", protocol, _Host.Trim('/').Trim('\\'), version);
84+
85+
// Resolve host from CDN-backed regions registry.
86+
string regionId = ContentstackRegionMap.RegionIdMap[Region];
87+
string host = Endpoint.GetContentstackEndpoint(regionId, "contentDelivery", omitHttps: true);
88+
return string.Format("{0}://{1}/{2}", protocol, host, version);
8589
}
8690
}
8791

@@ -109,22 +113,6 @@ internal string getBaseUrl (LivePreviewConfig livePreviewConfig, string contentT
109113
return BaseUrl;
110114
}
111115

112-
internal string regionCode()
113-
{
114-
if (Region == ContentstackRegion.US) return "";
115-
ContentstackRegionCode[] regionCodes = Enum.GetValues(typeof(ContentstackRegionCode)).Cast<ContentstackRegionCode>().ToArray();
116-
return string.Format("{0}-", regionCodes[(int)Region].ToString().Replace("_", "-"));
117-
}
118-
119-
internal string HostURL
120-
{
121-
get
122-
{
123-
if (Region == ContentstackRegion.EU || Region == ContentstackRegion.AZURE_EU || Region == ContentstackRegion.AZURE_NA || Region == ContentstackRegion.GCP_NA || Region==ContentstackRegion.AU)
124-
return "cdn.contentstack.com";
125-
return "cdn.contentstack.io";
126-
}
127-
}
128116
#endregion
129117
}
130118
}

0 commit comments

Comments
 (0)