Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ packages/
*.trx
*/TestResults/
*/app.config
.dccache
.dccache
*/Assets/regions.json
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
- 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>`).
- 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.
- Internal: `LangVersion` set to **latest** for multi-target builds; utilities normalize attribute values where the HTML pipeline expects strings.
- **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).
- **New:** `Utils.GetContentstackEndpoint(region, service)` proxy — access endpoint resolution directly from the `Utils` class without importing `Contentstack.Utils.Endpoints`.
- **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) }`).
- **New:** Case-insensitive region alias support — `"us"`, `"NA"`, `"AWS-NA"`, `"azure_na"` all resolve correctly to the same region.
- **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.
- **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.

### Version: 2.0.0-beta.1
#### Date: April-27-2026
Expand Down
263 changes: 263 additions & 0 deletions Contentstack.Utils.Tests/EndpointTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
using System;
using System.Collections.Generic;
using System.IO;
using Contentstack.Utils.Endpoints;
using Xunit;

namespace Contentstack.Utils.Tests
{
public class EndpointTest : IDisposable
{
// Reset cache before each test so tests are isolated.
public EndpointTest()
{
Endpoint.ResetCache();
}

public void Dispose()
{
Endpoint.ResetCache();
}

// ------------------------------------------------------------------
// Basic resolution
// ------------------------------------------------------------------

[Fact]
public void GetContentstackEndpoint_Na_ReturnsCorrectCdnUrl()
{
string url = Endpoint.GetContentstackEndpoint("na", "contentDelivery");
Assert.Equal("https://cdn.contentstack.io", url);
}

[Theory]
[InlineData("na")]
[InlineData("eu")]
[InlineData("au")]
[InlineData("azure-na")]
[InlineData("azure-eu")]
[InlineData("gcp-na")]
[InlineData("gcp-eu")]
public void GetContentstackEndpoint_AllRegionIds_Resolve(string regionId)
{
string url = Endpoint.GetContentstackEndpoint(regionId, "contentDelivery");
Assert.False(string.IsNullOrEmpty(url));
Assert.StartsWith("https://", url);
}

// ------------------------------------------------------------------
// Alias resolution (case-insensitive, dash/underscore variants)
// ------------------------------------------------------------------

[Theory]
[InlineData("na")]
[InlineData("us")]
[InlineData("NA")]
[InlineData("US")]
[InlineData("AWS-NA")]
[InlineData("aws_na")]
[InlineData("AWS_NA")]
public void GetContentstackEndpoint_NaAliasVariants_AllResolveToSameCdn(string alias)
{
string url = Endpoint.GetContentstackEndpoint(alias, "contentDelivery");
Assert.Equal("https://cdn.contentstack.io", url);
}

[Theory]
[InlineData("azure-na")]
[InlineData("azure_na")]
[InlineData("AZURE-NA")]
[InlineData("AZURE_NA")]
public void GetContentstackEndpoint_AzureNaAliasVariants_AllResolveToSameUrl(string alias)
{
string expected = Endpoint.GetContentstackEndpoint("azure-na", "contentDelivery");
string result = Endpoint.GetContentstackEndpoint(alias, "contentDelivery");
Assert.Equal(expected, result);
}

// ------------------------------------------------------------------
// omitHttps flag
// ------------------------------------------------------------------

[Fact]
public void GetContentstackEndpoint_OmitHttps_StripsScheme()
{
string host = Endpoint.GetContentstackEndpoint("na", "contentDelivery", omitHttps: true);
Assert.Equal("cdn.contentstack.io", host);
}

[Fact]
public void GetContentstackEndpoint_OmitHttps_EuRegion_StripsScheme()
{
string host = Endpoint.GetContentstackEndpoint("eu", "contentDelivery", omitHttps: true);
Assert.Equal("eu-cdn.contentstack.com", host);
}

[Fact]
public void GetContentstackEndpoint_OmitHttpsFalse_ReturnsFullUrl()
{
string url = Endpoint.GetContentstackEndpoint("na", "contentDelivery", omitHttps: false);
Assert.StartsWith("https://", url);
}

// ------------------------------------------------------------------
// All-endpoints (dict) overload
// ------------------------------------------------------------------

[Theory]
[InlineData("na")]
[InlineData("eu")]
[InlineData("au")]
[InlineData("azure-na")]
[InlineData("azure-eu")]
[InlineData("gcp-na")]
[InlineData("gcp-eu")]
public void GetContentstackEndpoint_AllEndpoints_ContainsExpectedKeys(string regionId)
{
var endpoints = Endpoint.GetContentstackEndpoint(regionId);
Assert.True(endpoints.Count > 0);
Assert.True(endpoints.ContainsKey("contentDelivery"));
Assert.True(endpoints.ContainsKey("contentManagement"));
Assert.True(endpoints.ContainsKey("auth"));
}

[Fact]
public void GetContentstackEndpoint_NaAllEndpoints_Has18Keys()
{
var endpoints = Endpoint.GetContentstackEndpoint("na");
Assert.Equal(18, endpoints.Count);
}

[Fact]
public void GetContentstackEndpoint_AllEndpoints_OmitHttps_AllValuesStripped()
{
var endpoints = Endpoint.GetContentstackEndpoint("na", omitHttps: true);
foreach (var url in endpoints.Values)
Assert.DoesNotContain("https://", url);
}

// ------------------------------------------------------------------
// Error handling
// ------------------------------------------------------------------

[Theory]
[InlineData("")]
[InlineData(" ")]
public void GetContentstackEndpoint_EmptyRegion_ThrowsArgumentException(string emptyRegion)
{
Assert.Throws<ArgumentException>(() =>
Endpoint.GetContentstackEndpoint(emptyRegion, "contentDelivery"));
}

[Theory]
[InlineData("")]
[InlineData(" ")]
public void GetContentstackEndpoint_EmptyRegion_DictOverload_ThrowsArgumentException(string emptyRegion)
{
Assert.Throws<ArgumentException>(() =>
Endpoint.GetContentstackEndpoint(emptyRegion));
}

[Fact]
public void GetContentstackEndpoint_UnknownRegion_ThrowsKeyNotFoundException()
{
Assert.Throws<KeyNotFoundException>(() =>
Endpoint.GetContentstackEndpoint("xyz", "contentDelivery"));
}

[Fact]
public void GetContentstackEndpoint_UnknownService_ThrowsKeyNotFoundException()
{
var ex = Assert.Throws<KeyNotFoundException>(() =>
Endpoint.GetContentstackEndpoint("na", "nonExistentService"));
Assert.Contains("nonExistentService", ex.Message);
}

// ------------------------------------------------------------------
// Cache behaviour
// ------------------------------------------------------------------

[Fact]
public void ResetCache_AllowsReload_NoError()
{
// First call populates cache
Endpoint.GetContentstackEndpoint("na", "contentDelivery");

// Reset and call again — should reload without error
Endpoint.ResetCache();
string url = Endpoint.GetContentstackEndpoint("na", "contentDelivery");
Assert.Equal("https://cdn.contentstack.io", url);
}

[Fact]
public void GetContentstackEndpoint_ConsecutiveCalls_ReturnConsistentResults()
{
string first = Endpoint.GetContentstackEndpoint("eu", "contentManagement");
string second = Endpoint.GetContentstackEndpoint("eu", "contentManagement");
Assert.Equal(first, second);
}

// ------------------------------------------------------------------
// Utils proxy parity
// ------------------------------------------------------------------

[Fact]
public void Utils_Proxy_String_MatchesEndpoint()
{
string direct = Endpoint.GetContentstackEndpoint("na", "contentDelivery");
string proxy = Utils.GetContentstackEndpoint("na", "contentDelivery");
Assert.Equal(direct, proxy);
}

[Fact]
public void Utils_Proxy_Dict_MatchesEndpoint()
{
var direct = Endpoint.GetContentstackEndpoint("eu");
var proxy = Utils.GetContentstackEndpoint("eu");
Assert.Equal(direct.Count, proxy.Count);
foreach (var kvp in direct)
Assert.Equal(kvp.Value, proxy[kvp.Key]);
}

[Fact]
public void Utils_Proxy_OmitHttps_MatchesEndpoint()
{
string direct = Endpoint.GetContentstackEndpoint("eu", "contentDelivery", omitHttps: true);
string proxy = Utils.GetContentstackEndpoint("eu", "contentDelivery", omitHttps: true);
Assert.Equal(direct, proxy);
}

// ------------------------------------------------------------------
Comment thread
OMpawar-21 marked this conversation as resolved.
// Local file self-heal.
// ------------------------------------------------------------------

[Fact]
public void LocalFile_WhenWritten_IsReadOnNextCacheReset()
{
// First call: either reads from disk or downloads from CDN and writes to disk.
Endpoint.GetContentstackEndpoint("na", "contentDelivery");

string localPath = Endpoint.GetLocalFilePath();

// If CDN download failed (no network in CI), skip this assertion — the
// self-heal path is covered by the CDN download succeeding at call time.
if (!File.Exists(localPath))
return;

// Reset cache — next call must read from local file (step 2 in LoadRegions),
// not re-download. Mirrors Python: os.path.exists(_REGIONS_FILE) → open().
Endpoint.ResetCache();
string url = Endpoint.GetContentstackEndpoint("na", "contentDelivery");

Assert.Equal("https://cdn.contentstack.io", url);
}

[Fact]
public void LocalFilePath_IsNextToDll_NotSourceDirectory()
{
string localPath = Endpoint.GetLocalFilePath();
Assert.Contains("Assets", localPath);
Assert.EndsWith("regions.json", localPath);
}
}
}
20 changes: 19 additions & 1 deletion Contentstack.Utils/Contentstack.Utils.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>netstandard2.0;net47;net472;</TargetFrameworks>
<TargetFrameworks>net10.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<PackageId>contentstack.utils</PackageId>
<RootNamespace>Contentstack.Utils</RootNamespace>
Expand Down Expand Up @@ -38,9 +38,27 @@
<Folder Include="Extensions\" />
<Folder Include="Converters\" />
<Folder Include="Constants\" />
<Folder Include="Endpoints\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.11.46" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
</ItemGroup>

<!-- Ship refresh-region.cs inside the NuGet package.
The .targets file copies it into the consumer's Scripts/ folder on first build.
Customer runs: dotnet run Scripts/refresh-region.cs -->
<ItemGroup>
<Content Include="..\Scripts\refresh-region.cs">
<Pack>true</Pack>
<PackagePath>contentFiles/cs/any/Scripts/refresh-region.cs</PackagePath>
<BuildAction>Content</BuildAction>
<CopyToOutput>false</CopyToOutput>
</Content>
<None Include="..\build\contentstack.utils.targets">
<Pack>true</Pack>
<PackagePath>build/contentstack.utils.targets</PackagePath>
</None>
</ItemGroup>

</Project>
Loading
Loading