Skip to content
Draft

Beta #73

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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
### Version: 2.0.0-beta.2
#### Date: June-22-2026
- **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.py` bundled inside the NuGet package — automatically placed in your project's `Scripts/` folder on first `dotnet build`. Run `python3 Scripts/refresh-region.py` (Mac/Linux) or `python Scripts/refresh-region.py` (Windows) anytime to pull the latest regions from CDN.

### Version: 2.0.0-beta.1
#### Date: April-27-2026
- **Breaking:** Replaced **Newtonsoft.Json** with **System.Text.Json** across the package. The `Newtonsoft.Json` package reference is removed; add `System.Text.Json` (or rely on the BCL on supported runtimes) as needed in consuming projects.
- **Breaking:** Variant metadata APIs that previously took `JObject` / `JArray` now use `System.Text.Json.Nodes.JsonObject` and `JsonArray` (`GetVariantAliases`, `GetVariantMetadataTags`, and obsolete `GetDataCsvariantsAttribute` overloads).
- 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.

### Version: 1.2.0
#### Date: March-31-2026
- Added `GetVariantMetadataTags(JObject, string)` and `GetVariantMetadataTags(JArray, string)` as the canonical API for building the `data-csvariants` payload (same behavior as the previous helpers).
Expand Down
3 changes: 1 addition & 2 deletions Contentstack.Utils.Tests/Contentstack.Utils.Tests.csproj
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net7.0</TargetFrameworks>
<TargetFrameworks>net10.0</TargetFrameworks>

<IsPackable>false</IsPackable>
<ReleaseVersion>$(Version)</ReleaseVersion>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
Expand Down
7 changes: 4 additions & 3 deletions Contentstack.Utils.Tests/DefaultRenderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Contentstack.Utils.Tests.Helpers;
using Contentstack.Utils.Tests.Constants;
using System;
using Contentstack.Utils;

namespace Contentstack.Utils.Tests
{
Expand Down Expand Up @@ -178,9 +179,9 @@ public void testLinkhDocument()

string result = defaultRender.RenderNode("a", nodeLink, (nodes) => { return text; });

string url = nodeLink.attrs.ContainsKey("url") ? (string)nodeLink.attrs["url"] : "";
string target = nodeLink.attrs.ContainsKey("target") ? (string)nodeLink.attrs["target"] : "";
string title = nodeLink.attrs.ContainsKey("title") ? (string)nodeLink.attrs["title"] : "";
string url = nodeLink.attrs.ContainsKey("url") ? JsonAttrValue.AsString(nodeLink.attrs["url"]) : "";
string target = nodeLink.attrs.ContainsKey("target") ? JsonAttrValue.AsString(nodeLink.attrs["target"]) : "";
string title = nodeLink.attrs.ContainsKey("title") ? JsonAttrValue.AsString(nodeLink.attrs["title"]) : "";

Assert.Equal($"<a href=\"{url}\" target=\"{target}\" title=\"{title}\" >Text To set Link</a>", result);
}
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);
}

// ------------------------------------------------------------------
// 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);
}
}
}
28 changes: 16 additions & 12 deletions Contentstack.Utils.Tests/Helpers/NodeParser.cs
Original file line number Diff line number Diff line change
@@ -1,31 +1,35 @@
using Contentstack.Utils.Interfaces;
using System.Text.Json;
using Contentstack.Utils.Interfaces;
using Contentstack.Utils.Models;
using Contentstack.Utils.Tests.Constants;
using Contentstack.Utils.Tests.Mocks;
using Newtonsoft.Json;

namespace Contentstack.Utils.Tests.Helpers
{
public class NodeParser
{
public static Node parse(string jsonNode)
private static readonly JsonSerializerOptions SerializerSettings = new JsonSerializerOptions
{
JsonSerializerSettings SerializerSettings = new JsonSerializerSettings();
JsonSerializer Serializer = JsonSerializer.Create(SerializerSettings);
AllowTrailingCommas = true,
};

return JsonConvert.DeserializeObject<Node>(jsonNode, SerializerSettings);
public static Node parse(string jsonNode)
{
return JsonSerializer.Deserialize<Node>(jsonNode, SerializerSettings);
}
}

public class GQLParser
{
public static GQLModel<T> parse<T>(string jsonNode, string embedConnection = null) where T: IEmbeddedObject
private static readonly JsonSerializerOptions SerializerSettings = new JsonSerializerOptions
{
AllowTrailingCommas = true,
};

public static GQLModel<T> parse<T>(string jsonNode, string embedConnection = null) where T : IEmbeddedObject
{
var data = JsonToHtmlConstants.KGQLModel(jsonNode, embedConnection);
JsonSerializerSettings SerializerSettings = new JsonSerializerSettings();
JsonSerializer Serializer = JsonSerializer.Create(SerializerSettings);
return JsonConvert.DeserializeObject<GQLModel<T>>(data, SerializerSettings);
return JsonSerializer.Deserialize<GQLModel<T>>(data, SerializerSettings);
}
}

}

5 changes: 3 additions & 2 deletions Contentstack.Utils.Tests/Mocks/CustomRenderOptionMock.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using Contentstack.Utils;
using Contentstack.Utils.Interfaces;
using Contentstack.Utils.Models;
using HtmlAgilityPack;
Expand All @@ -17,9 +18,9 @@ public override string RenderNode(string nodeType, Node node, NodeChildrenCallBa
case "a":
if (node.attrs.ContainsKey("target"))
{
return $"<a href=\"{(string)node.attrs["url"]}\" target=\"{(string)node.attrs["target"]}\">{callBack(node.children)}</a>";
return $"<a href=\"{JsonAttrValue.AsString(node.attrs["url"])}\" target=\"{JsonAttrValue.AsString(node.attrs["target"])}\">{callBack(node.children)}</a>";
}
return $"<a href=\"{(string)node.attrs["url"]}\">{callBack(node.children)}</a>";
return $"<a href=\"{JsonAttrValue.AsString(node.attrs["url"])}\">{callBack(node.children)}</a>";
}
return base.RenderNode(nodeType, node, callBack);
}
Expand Down
Loading
Loading