diff --git a/.gitignore b/.gitignore index 000500d..353cc9c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,5 @@ packages/ *.trx */TestResults/ */app.config -.dccache \ No newline at end of file +.dccache +*/Assets/regions.json \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 07d834b..c1cb3d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,39 @@ +### Version: 2.0.0 +#### Date: July-13-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:** `GetVariantAliases(JObject, string)` / `GetVariantAliases(JArray, string)` → `GetVariantAliases(JsonObject, string)` / `GetVariantAliases(JsonArray, string)`. +- **Breaking:** `GetVariantMetadataTags(JObject, string)` / `GetVariantMetadataTags(JArray, string)` → `GetVariantMetadataTags(JsonObject, string)` / `GetVariantMetadataTags(JsonArray, string)`. +- **Breaking:** `GetDataCsvariantsAttribute(JObject/JArray, string)` is deprecated and now maps to `JsonObject`/`JsonArray` overloads. +- **Breaking:** `Node.attrs` values are now `System.Text.Json.JsonElement` instead of `Newtonsoft.Json.Linq.JToken` — update `node.attrs["src"].Value()` to `node.attrs["src"].GetString()`. +- **Breaking:** Model classes use `[JsonPropertyName]` instead of `[JsonProperty]`. +- **Breaking:** Requires **.NET 10** or later (previously netstandard2.0 / net6.0). +- **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. + +##### Migration Guide: +- See [Migrating from Newtonsoft.Json to System.Text.Json](https://www.contentstack.com/docs/developers/sdks/utils-sdk/dot-net/migrate-dotnet-utils-sdk-from-newtonsoft.json-to-system.text.json) for the full upgrade path from v1.x. + +### 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`). +- 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.4.0 #### Date: June-23-2026 - Added `EmbeddedObject` as a concrete implementation of `IEmbeddedObject`, covering both `IEmbeddedEntry` and `IEmbeddedAsset`. @@ -13,7 +49,7 @@ - Added configurable locale casing through `AddEditableTagsOptions.UseLowerCaseLocale`. - Added unit tests for Live Preview editable tags. - +### 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). diff --git a/Contentstack.Utils.Tests/Contentstack.Utils.Tests.csproj b/Contentstack.Utils.Tests/Contentstack.Utils.Tests.csproj index 4d4e3d4..f8e4299 100644 --- a/Contentstack.Utils.Tests/Contentstack.Utils.Tests.csproj +++ b/Contentstack.Utils.Tests/Contentstack.Utils.Tests.csproj @@ -1,7 +1,7 @@ - net7.0 + net10.0 false $(Version) @@ -9,7 +9,6 @@ - runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Contentstack.Utils.Tests/DefaultRenderTest.cs b/Contentstack.Utils.Tests/DefaultRenderTest.cs index 6f44ee3..96d1155 100644 --- a/Contentstack.Utils.Tests/DefaultRenderTest.cs +++ b/Contentstack.Utils.Tests/DefaultRenderTest.cs @@ -5,6 +5,7 @@ using Contentstack.Utils.Tests.Helpers; using Contentstack.Utils.Tests.Constants; using System; +using Contentstack.Utils; namespace Contentstack.Utils.Tests { @@ -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($"Text To set Link", result); } diff --git a/Contentstack.Utils.Tests/EmbeddedObjectConverterTest.cs b/Contentstack.Utils.Tests/EmbeddedObjectConverterTest.cs index 98ac680..f19fc4d 100644 --- a/Contentstack.Utils.Tests/EmbeddedObjectConverterTest.cs +++ b/Contentstack.Utils.Tests/EmbeddedObjectConverterTest.cs @@ -1,11 +1,12 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; using Contentstack.Utils.Converters; using Contentstack.Utils.Interfaces; using Contentstack.Utils.Models; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; using Xunit; namespace Contentstack.Utils.Tests @@ -22,17 +23,17 @@ private static string ReadJson(string fileName) return File.ReadAllText(path); } - private static JsonSerializerSettings SettingsWithConverter() + private static JsonSerializerOptions OptionsWithConverter() { - var settings = new JsonSerializerSettings(); - settings.Converters.Add(new EmbeddedObjectConverter()); - return settings; + var options = new JsonSerializerOptions(); + options.Converters.Add(new EmbeddedObjectConverter()); + return options; } private static EmbeddedObject DeserializeSingle(string json) { - return (EmbeddedObject)JsonConvert.DeserializeObject( - json, SettingsWithConverter()); + return (EmbeddedObject)JsonSerializer.Deserialize( + json, OptionsWithConverter()); } // ── EmbeddedObjectConverter.CanConvert ──────────────────────────────── @@ -59,7 +60,7 @@ public void CanConvert_IEmbeddedAsset_ReturnsFalse() [Fact] public void CanConvert_EmbeddedObject_ReturnsFalse() { - // Concrete class must fall through to Newtonsoft default property mapping. + // Concrete class must fall through to System.Text.Json default property mapping. Assert.False(_converter.CanConvert(typeof(EmbeddedObject))); } @@ -70,64 +71,60 @@ public void CanConvert_CustomerSubclass_ReturnsFalse() Assert.False(_converter.CanConvert(typeof(CustomerDefinedEmbeddedObject))); } - [Fact] - public void CanWrite_IsFalse() - { - Assert.False(_converter.CanWrite); - } - - // ── EmbeddedObjectConverter.WriteJson ───────────────────────────────── + // ── EmbeddedObjectConverter.Write (read-only converter) ─────────────── [Fact] - public void WriteJson_ThrowsNotSupportedException() + public void Write_ThrowsNotSupportedException() { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); Assert.Throws(() => - _converter.WriteJson(null, new EmbeddedObject(), new JsonSerializer())); + _converter.Write(writer, new EmbeddedObject(), new JsonSerializerOptions())); } - // ── ReadJson — null token ───────────────────────────────────────────── + // ── Read — null token ───────────────────────────────────────────────── [Fact] - public void ReadJson_NullToken_ReturnsNull() + public void Read_NullToken_ReturnsNull() { - var result = JsonConvert.DeserializeObject("null", SettingsWithConverter()); + var result = JsonSerializer.Deserialize("null", OptionsWithConverter()); Assert.Null(result); } - // ── ReadJson — single embedded entry (embeddedEntry.json) ──────────── + // ── Read — single embedded entry (embeddedEntry.json) ──────────────── [Fact] - public void ReadJson_EntryJson_ReturnsEmbeddedObject() + public void Read_EntryJson_ReturnsEmbeddedObject() { - var result = JsonConvert.DeserializeObject( - ReadJson("embeddedEntry.json"), SettingsWithConverter()); + var result = JsonSerializer.Deserialize( + ReadJson("embeddedEntry.json"), OptionsWithConverter()); Assert.NotNull(result); Assert.IsType(result); } [Fact] - public void ReadJson_EntryJson_PopulatesUid() + public void Read_EntryJson_PopulatesUid() { var result = DeserializeSingle(ReadJson("embeddedEntry.json")); Assert.Equal("sample_author_uid", result.Uid); } [Fact] - public void ReadJson_EntryJson_PopulatesContentTypeUid() + public void Read_EntryJson_PopulatesContentTypeUid() { var result = DeserializeSingle(ReadJson("embeddedEntry.json")); Assert.Equal("author", result.ContentTypeUid); } [Fact] - public void ReadJson_EntryJson_PopulatesTitle_ViaIEmbeddedEntry() + public void Read_EntryJson_PopulatesTitle_ViaIEmbeddedEntry() { var result = DeserializeSingle(ReadJson("embeddedEntry.json")); Assert.Equal("Dummy User", (result as IEmbeddedEntry)?.Title); } [Fact] - public void ReadJson_EntryJson_CustomFields_CapturedInFields() + public void Read_EntryJson_CustomFields_CapturedInFields() { var result = DeserializeSingle(ReadJson("embeddedEntry.json")); Assert.True(result.Fields.ContainsKey("bio")); @@ -135,36 +132,37 @@ public void ReadJson_EntryJson_CustomFields_CapturedInFields() Assert.True(result.Fields.ContainsKey("avatar_url")); Assert.Equal( "This is a dummy bio used for testing purposes.", - result.Fields["bio"].ToString()); - Assert.Equal("dummy.user@example.com", result.Fields["email"].ToString()); + result.Fields["bio"].GetString()); + Assert.Equal("dummy.user@example.com", result.Fields["email"].GetString()); } [Fact] - public void ReadJson_EntryJson_NestedObjectField_CapturedInFields() + public void Read_EntryJson_NestedObjectField_CapturedInFields() { - // social is a nested object — must land in Fields as a JObject, not be dropped. + // social is a nested object — must land in Fields as an object element, not be dropped. var result = DeserializeSingle(ReadJson("embeddedEntry.json")); Assert.True(result.Fields.ContainsKey("social")); - var social = result.Fields["social"] as JObject; - Assert.NotNull(social); - Assert.Equal("@dummyuser", social["twitter"]?.ToString()); - Assert.Equal("linkedin.com/in/dummyuser", social["linkedin"]?.ToString()); + var social = result.Fields["social"]; + Assert.Equal(JsonValueKind.Object, social.ValueKind); + Assert.Equal("@dummyuser", social.GetProperty("twitter").GetString()); + Assert.Equal("linkedin.com/in/dummyuser", social.GetProperty("linkedin").GetString()); } [Fact] - public void ReadJson_EntryJson_ArrayField_CapturedInFields() + public void Read_EntryJson_ArrayField_CapturedInFields() { - // tags is a JSON array — must land in Fields as a JArray. + // tags is a JSON array — must land in Fields as an array element. var result = DeserializeSingle(ReadJson("embeddedEntry.json")); Assert.True(result.Fields.ContainsKey("tags")); - var tags = result.Fields["tags"] as JArray; - Assert.NotNull(tags); - Assert.Equal(3, tags.Count); - Assert.Contains("sample", tags.ToObject>()); + var tags = result.Fields["tags"]; + Assert.Equal(JsonValueKind.Array, tags.ValueKind); + Assert.Equal(3, tags.GetArrayLength()); + var tagValues = tags.EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.Contains("sample", tagValues); } [Fact] - public void ReadJson_EntryJson_KnownFields_NotDuplicatedInExtensionData() + public void Read_EntryJson_KnownFields_NotDuplicatedInExtensionData() { // uid, _content_type_uid, title are declared properties — must NOT appear in Fields. var result = DeserializeSingle(ReadJson("embeddedEntry.json")); @@ -173,10 +171,10 @@ public void ReadJson_EntryJson_KnownFields_NotDuplicatedInExtensionData() Assert.False(result.Fields.ContainsKey("title")); } - // ── ReadJson — single embedded asset (embeddedAsset.json) ──────────── + // ── Read — single embedded asset (embeddedAsset.json) ──────────────── [Fact] - public void ReadJson_AssetJson_PopulatesFileName_ViaIEmbeddedAsset() + public void Read_AssetJson_PopulatesFileName_ViaIEmbeddedAsset() { var result = DeserializeSingle(ReadJson("embeddedAsset.json")); var asAsset = result as IEmbeddedAsset; @@ -185,36 +183,36 @@ public void ReadJson_AssetJson_PopulatesFileName_ViaIEmbeddedAsset() } [Fact] - public void ReadJson_AssetJson_PopulatesUrl() + public void Read_AssetJson_PopulatesUrl() { var result = DeserializeSingle(ReadJson("embeddedAsset.json")); Assert.Contains("dummy-image.jpg", result.Url); } [Fact] - public void ReadJson_AssetJson_ContentTypeUid_IsSysAssets() + public void Read_AssetJson_ContentTypeUid_IsSysAssets() { var result = DeserializeSingle(ReadJson("embeddedAsset.json")); Assert.Equal("sys_assets", result.ContentTypeUid); } [Fact] - public void ReadJson_AssetJson_DimensionObject_CapturedInFields() + public void Read_AssetJson_DimensionObject_CapturedInFields() { var result = DeserializeSingle(ReadJson("embeddedAsset.json")); Assert.True(result.Fields.ContainsKey("dimension")); - var dimension = result.Fields["dimension"] as JObject; - Assert.NotNull(dimension); - Assert.Equal(100, dimension["height"]?.Value()); - Assert.Equal(100, dimension["width"]?.Value()); + var dimension = result.Fields["dimension"]; + Assert.Equal(JsonValueKind.Object, dimension.ValueKind); + Assert.Equal(100, dimension.GetProperty("height").GetInt32()); + Assert.Equal(100, dimension.GetProperty("width").GetInt32()); } [Fact] - public void ReadJson_AssetJson_FileSize_CapturedInFields() + public void Read_AssetJson_FileSize_CapturedInFields() { var result = DeserializeSingle(ReadJson("embeddedAsset.json")); Assert.True(result.Fields.ContainsKey("file_size")); - Assert.Equal("10000", result.Fields["file_size"].ToString()); + Assert.Equal("10000", result.Fields["file_size"].GetString()); } // ── Integration — List (embeddedItems.json) ───────── @@ -222,26 +220,27 @@ public void ReadJson_AssetJson_FileSize_CapturedInFields() [Fact] public void Deserialize_ListOfIEmbeddedObject_WithConverter_Succeeds() { - var result = JsonConvert.DeserializeObject>( - ReadJson("embeddedItems.json"), SettingsWithConverter()); + var result = JsonSerializer.Deserialize>( + ReadJson("embeddedItems.json"), OptionsWithConverter()); Assert.NotNull(result); Assert.Equal(3, result.Count); Assert.All(result, item => Assert.IsType(item)); } [Fact] - public void Deserialize_ListOfIEmbeddedObject_WithoutConverter_ThrowsJsonSerializationException() + public void Deserialize_ListOfIEmbeddedObject_WithoutConverter_Throws() { - // Reproduces the customer crash on contentstack.csharp v2.27.0. - Assert.Throws(() => - JsonConvert.DeserializeObject>(ReadJson("embeddedItems.json"))); + // Reproduces the customer crash: System.Text.Json cannot instantiate the + // IEmbeddedObject interface without the converter registered. + Assert.Throws(() => + JsonSerializer.Deserialize>(ReadJson("embeddedItems.json"))); } [Fact] public void Deserialize_ListItem_Entry_HasCorrectKnownFields() { - var result = JsonConvert.DeserializeObject>( - ReadJson("embeddedItems.json"), SettingsWithConverter()); + var result = JsonSerializer.Deserialize>( + ReadJson("embeddedItems.json"), OptionsWithConverter()); var entry = result[0] as EmbeddedObject; Assert.Equal("sample_author_uid", entry.Uid); Assert.Equal("author", entry.ContentTypeUid); @@ -251,19 +250,19 @@ public void Deserialize_ListItem_Entry_HasCorrectKnownFields() [Fact] public void Deserialize_ListItem_Entry_HasCustomFields() { - var result = JsonConvert.DeserializeObject>( - ReadJson("embeddedItems.json"), SettingsWithConverter()); + var result = JsonSerializer.Deserialize>( + ReadJson("embeddedItems.json"), OptionsWithConverter()); var entry = result[0] as EmbeddedObject; - Assert.Equal("dummy.user@example.com", entry.Fields["email"].ToString()); + Assert.Equal("dummy.user@example.com", entry.Fields["email"].GetString()); Assert.Equal("https://example.com/dummy-avatar.jpg", - entry.Fields["avatar_url"].ToString()); + entry.Fields["avatar_url"].GetString()); } [Fact] public void Deserialize_ListItem_FirstAsset_HasCorrectFields() { - var result = JsonConvert.DeserializeObject>( - ReadJson("embeddedItems.json"), SettingsWithConverter()); + var result = JsonSerializer.Deserialize>( + ReadJson("embeddedItems.json"), OptionsWithConverter()); var asset = result[1] as EmbeddedObject; Assert.Equal("sample_asset_uid", asset.Uid); Assert.Equal("sys_assets", asset.ContentTypeUid); @@ -273,8 +272,8 @@ public void Deserialize_ListItem_FirstAsset_HasCorrectFields() [Fact] public void Deserialize_ListItem_SecondAsset_HasCorrectFields() { - var result = JsonConvert.DeserializeObject>( - ReadJson("embeddedItems.json"), SettingsWithConverter()); + var result = JsonSerializer.Deserialize>( + ReadJson("embeddedItems.json"), OptionsWithConverter()); var asset = result[2] as EmbeddedObject; Assert.Equal("sample_asset_uid_2", asset.Uid); Assert.Equal("dummy-image.png", asset.FileName); @@ -283,14 +282,17 @@ public void Deserialize_ListItem_SecondAsset_HasCorrectFields() // ── Full entry deserialization (rteEntryWithEmbeddedItems.json) ─────── // Mirrors the actual Delivery API response shape: { "entry": { ..., "_embedded_items": { ... } } } + private static string ReadEmbeddedItemsToken(string fileName) + { + var root = JsonNode.Parse(ReadJson(fileName)); + return root["entry"]["_embedded_items"]["rte_json"].ToJsonString(); + } + [Fact] public void FullEntry_EmbeddedItems_DeserializesAllThreeItems() { - var root = JObject.Parse(ReadJson("rteEntryWithEmbeddedItems.json")); - var embeddedItemsToken = root["entry"]["_embedded_items"]["rte_json"].ToString(); - - var result = JsonConvert.DeserializeObject>( - embeddedItemsToken, SettingsWithConverter()); + var result = JsonSerializer.Deserialize>( + ReadEmbeddedItemsToken("rteEntryWithEmbeddedItems.json"), OptionsWithConverter()); Assert.Equal(3, result.Count); } @@ -298,24 +300,20 @@ public void FullEntry_EmbeddedItems_DeserializesAllThreeItems() [Fact] public void FullEntry_EmbeddedItems_FirstItem_IsAuthorEntry() { - var root = JObject.Parse(ReadJson("rteEntryWithEmbeddedItems.json")); - var embeddedItemsToken = root["entry"]["_embedded_items"]["rte_json"].ToString(); - var result = JsonConvert.DeserializeObject>( - embeddedItemsToken, SettingsWithConverter()); + var result = JsonSerializer.Deserialize>( + ReadEmbeddedItemsToken("rteEntryWithEmbeddedItems.json"), OptionsWithConverter()); var author = result[0] as EmbeddedObject; Assert.Equal("author", author.ContentTypeUid); Assert.Equal("Dummy User", author.Title); - Assert.Equal("dummy.user@example.com", author.Fields["email"].ToString()); + Assert.Equal("dummy.user@example.com", author.Fields["email"].GetString()); } [Fact] public void FullEntry_EmbeddedItems_SecondAndThirdItems_AreAssets() { - var root = JObject.Parse(ReadJson("rteEntryWithEmbeddedItems.json")); - var embeddedItemsToken = root["entry"]["_embedded_items"]["rte_json"].ToString(); - var result = JsonConvert.DeserializeObject>( - embeddedItemsToken, SettingsWithConverter()); + var result = JsonSerializer.Deserialize>( + ReadEmbeddedItemsToken("rteEntryWithEmbeddedItems.json"), OptionsWithConverter()); Assert.Equal("sys_assets", (result[1] as EmbeddedObject)?.ContentTypeUid); Assert.Equal("sys_assets", (result[2] as EmbeddedObject)?.ContentTypeUid); @@ -324,30 +322,26 @@ public void FullEntry_EmbeddedItems_SecondAndThirdItems_AreAssets() [Fact] public void FullEntry_EmbeddedItems_AssetDimension_CapturedInFields() { - var root = JObject.Parse(ReadJson("rteEntryWithEmbeddedItems.json")); - var embeddedItemsToken = root["entry"]["_embedded_items"]["rte_json"].ToString(); - var result = JsonConvert.DeserializeObject>( - embeddedItemsToken, SettingsWithConverter()); + var result = JsonSerializer.Deserialize>( + ReadEmbeddedItemsToken("rteEntryWithEmbeddedItems.json"), OptionsWithConverter()); var asset = result[1] as EmbeddedObject; - var dimension = asset.Fields["dimension"] as JObject; - Assert.NotNull(dimension); - Assert.Equal(100, dimension["height"]?.Value()); - Assert.Equal(100, dimension["width"]?.Value()); + var dimension = asset.Fields["dimension"]; + Assert.Equal(JsonValueKind.Object, dimension.ValueKind); + Assert.Equal(100, dimension.GetProperty("height").GetInt32()); + Assert.Equal(100, dimension.GetProperty("width").GetInt32()); } [Fact] public void FullEntry_EmbeddedItems_AuthorSocialObject_CapturedInFields() { - var root = JObject.Parse(ReadJson("rteEntryWithEmbeddedItems.json")); - var embeddedItemsToken = root["entry"]["_embedded_items"]["rte_json"].ToString(); - var result = JsonConvert.DeserializeObject>( - embeddedItemsToken, SettingsWithConverter()); + var result = JsonSerializer.Deserialize>( + ReadEmbeddedItemsToken("rteEntryWithEmbeddedItems.json"), OptionsWithConverter()); var author = result[0] as EmbeddedObject; - var social = author.Fields["social"] as JObject; - Assert.NotNull(social); - Assert.Equal("@dummyuser", social["twitter"]?.ToString()); + var social = author.Fields["social"]; + Assert.Equal(JsonValueKind.Object, social.ValueKind); + Assert.Equal("@dummyuser", social.GetProperty("twitter").GetString()); } // ── EmbeddedObject default state ────────────────────────────────────── diff --git a/Contentstack.Utils.Tests/EndpointTest.cs b/Contentstack.Utils.Tests/EndpointTest.cs new file mode 100644 index 0000000..2bb5ef8 --- /dev/null +++ b/Contentstack.Utils.Tests/EndpointTest.cs @@ -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(() => + Endpoint.GetContentstackEndpoint(emptyRegion, "contentDelivery")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void GetContentstackEndpoint_EmptyRegion_DictOverload_ThrowsArgumentException(string emptyRegion) + { + Assert.Throws(() => + Endpoint.GetContentstackEndpoint(emptyRegion)); + } + + [Fact] + public void GetContentstackEndpoint_UnknownRegion_ThrowsKeyNotFoundException() + { + Assert.Throws(() => + Endpoint.GetContentstackEndpoint("xyz", "contentDelivery")); + } + + [Fact] + public void GetContentstackEndpoint_UnknownService_ThrowsKeyNotFoundException() + { + var ex = Assert.Throws(() => + 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); + } + } +} diff --git a/Contentstack.Utils.Tests/Helpers/NodeParser.cs b/Contentstack.Utils.Tests/Helpers/NodeParser.cs index 4ee6ac7..6648754 100644 --- a/Contentstack.Utils.Tests/Helpers/NodeParser.cs +++ b/Contentstack.Utils.Tests/Helpers/NodeParser.cs @@ -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(jsonNode, SerializerSettings); + public static Node parse(string jsonNode) + { + return JsonSerializer.Deserialize(jsonNode, SerializerSettings); } } + public class GQLParser { - public static GQLModel parse(string jsonNode, string embedConnection = null) where T: IEmbeddedObject + private static readonly JsonSerializerOptions SerializerSettings = new JsonSerializerOptions + { + AllowTrailingCommas = true, + }; + + public static GQLModel parse(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>(data, SerializerSettings); + return JsonSerializer.Deserialize>(data, SerializerSettings); } } - } - diff --git a/Contentstack.Utils.Tests/LivePreviewTagsTest.cs b/Contentstack.Utils.Tests/LivePreviewTagsTest.cs index 5e5338f..4ec5e7e 100644 --- a/Contentstack.Utils.Tests/LivePreviewTagsTest.cs +++ b/Contentstack.Utils.Tests/LivePreviewTagsTest.cs @@ -3,8 +3,6 @@ using Xunit; using Contentstack.Utils.Models; using Contentstack.Utils.Interfaces; -using Newtonsoft.Json.Linq; -using System.IO; namespace Contentstack.Utils.Tests { @@ -16,12 +14,6 @@ public class LivePreviewTagsTest { #region Test Data and Helpers - private static JObject ReadJsonRoot(string fileName) - { - var path = Path.Combine(AppContext.BaseDirectory, "Resources", fileName); - return JObject.Parse(File.ReadAllText(path)); - } - private Dictionary CreateBasicEntry() { return new Dictionary diff --git a/Contentstack.Utils.Tests/Mocks/CustomRenderOptionMock.cs b/Contentstack.Utils.Tests/Mocks/CustomRenderOptionMock.cs index 53b37da..1983150 100644 --- a/Contentstack.Utils.Tests/Mocks/CustomRenderOptionMock.cs +++ b/Contentstack.Utils.Tests/Mocks/CustomRenderOptionMock.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Contentstack.Utils; using Contentstack.Utils.Interfaces; using Contentstack.Utils.Models; using HtmlAgilityPack; @@ -17,9 +18,9 @@ public override string RenderNode(string nodeType, Node node, NodeChildrenCallBa case "a": if (node.attrs.ContainsKey("target")) { - return $"{callBack(node.children)}"; + return $"{callBack(node.children)}"; } - return $"{callBack(node.children)}"; + return $"{callBack(node.children)}"; } return base.RenderNode(nodeType, node, callBack); } diff --git a/Contentstack.Utils.Tests/Mocks/GQLModel.cs b/Contentstack.Utils.Tests/Mocks/GQLModel.cs index 586ff39..d4cc33d 100644 --- a/Contentstack.Utils.Tests/Mocks/GQLModel.cs +++ b/Contentstack.Utils.Tests/Mocks/GQLModel.cs @@ -2,33 +2,32 @@ using Contentstack.Utils.Converters; using Contentstack.Utils.Interfaces; using Contentstack.Utils.Models; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Contentstack.Utils.Tests.Mocks { - public class GQLModel where T: IEmbeddedObject + public class GQLModel where T : IEmbeddedObject { - [Newtonsoft.Json.JsonConverter(typeof(RTEJsonConverter))] public JsonRTENodes multiplerte { get; set; } public JsonRTENode singlerte { get; set; } } - [Newtonsoft.Json.JsonConverter(typeof(RTEJsonConverter))] + [JsonConverter(typeof(PathMappedJsonConverter))] public class EntryModel : IEmbeddedEntry { - [JsonProperty("system.uid")] + [JsonPropertyName("system.uid")] public string Uid { get; set; } - [JsonProperty("system.content_type_uid")] + [JsonPropertyName("system.content_type_uid")] public string ContentTypeUid { get; set; } - [JsonProperty("title")] + [JsonPropertyName("title")] public string Title { get; @@ -36,34 +35,34 @@ public string Title } } - [Newtonsoft.Json.JsonConverter(typeof(RTEJsonConverter))] + [JsonConverter(typeof(PathMappedJsonConverter))] public class AssetModel : IEmbeddedAsset { - [JsonProperty("system.uid")] + [JsonPropertyName("system.uid")] public string Uid { get; set; } - [JsonProperty("system.content_type_uid")] + [JsonPropertyName("system.content_type_uid")] public string ContentTypeUid { get; set; } - [JsonProperty("title")] + [JsonPropertyName("title")] public string Title { get; set; } - [JsonProperty("filename")] + [JsonPropertyName("filename")] public string FileName { get; set; } - [JsonProperty("url")] + [JsonPropertyName("url")] public string Url { get; @@ -71,4 +70,3 @@ public string Url } } } - diff --git a/Contentstack.Utils.Tests/VariantAliasesTest.cs b/Contentstack.Utils.Tests/VariantAliasesTest.cs index 868f60e..1260b03 100644 --- a/Contentstack.Utils.Tests/VariantAliasesTest.cs +++ b/Contentstack.Utils.Tests/VariantAliasesTest.cs @@ -1,20 +1,20 @@ using System; using System.Collections.Generic; using System.IO; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; using Xunit; namespace Contentstack.Utils.Tests { public class VariantAliasesTest { - private static JObject ReadJsonRoot(string fileName) + private static JsonObject ReadJsonRoot(string fileName) { var path = Path.Combine(AppContext.BaseDirectory, "Resources", fileName); - return JObject.Parse(File.ReadAllText(path)); + return JsonNode.Parse(File.ReadAllText(path)).AsObject(); } - private static HashSet JsonArrayToStringSet(JArray arr) + private static HashSet JsonArrayToStringSet(JsonArray arr) { var set = new HashSet(); foreach (var t in arr) @@ -27,15 +27,15 @@ private static HashSet JsonArrayToStringSet(JArray arr) [Fact] public void GetVariantAliases_SingleEntry_ReturnsAliases() { - JObject full = ReadJsonRoot("variantsSingleEntry.json"); - JObject entry = (JObject)full["entry"]; + JsonObject full = ReadJsonRoot("variantsSingleEntry.json"); + JsonObject entry = full["entry"].AsObject(); const string contentTypeUid = "movie"; - JObject result = Utils.GetVariantAliases(entry, contentTypeUid); + JsonObject result = Utils.GetVariantAliases(entry, contentTypeUid); Assert.True(result["entry_uid"] != null && !string.IsNullOrEmpty(result["entry_uid"].ToString())); Assert.Equal(contentTypeUid, result["contenttype_uid"].ToString()); - JArray variants = (JArray)result["variants"]; + JsonArray variants = result["variants"].AsArray(); Assert.NotNull(variants); var aliasSet = JsonArrayToStringSet(variants); Assert.Equal( @@ -46,20 +46,20 @@ public void GetVariantAliases_SingleEntry_ReturnsAliases() [Fact] public void GetVariantMetadataTags_SingleEntry_ReturnsJsonArrayString() { - JObject full = ReadJsonRoot("variantsSingleEntry.json"); - JObject entry = (JObject)full["entry"]; + JsonObject full = ReadJsonRoot("variantsSingleEntry.json"); + JsonObject entry = full["entry"].AsObject(); const string contentTypeUid = "movie"; - JObject result = Utils.GetVariantMetadataTags(entry, contentTypeUid); + JsonObject result = Utils.GetVariantMetadataTags(entry, contentTypeUid); Assert.True(result["data-csvariants"] != null); string dataCsvariantsStr = result["data-csvariants"].ToString(); - JArray arr = JArray.Parse(dataCsvariantsStr); + JsonArray arr = JsonNode.Parse(dataCsvariantsStr).AsArray(); Assert.Single(arr); - JObject first = (JObject)arr[0]; + JsonObject first = arr[0].AsObject(); Assert.True(first["entry_uid"] != null && !string.IsNullOrEmpty(first["entry_uid"].ToString())); Assert.Equal(contentTypeUid, first["contenttype_uid"].ToString()); - var aliasSet = JsonArrayToStringSet((JArray)first["variants"]); + var aliasSet = JsonArrayToStringSet(first["variants"].AsArray()); Assert.Equal( new HashSet { "cs_personalize_0_0", "cs_personalize_0_3" }, aliasSet); @@ -68,80 +68,80 @@ public void GetVariantMetadataTags_SingleEntry_ReturnsJsonArrayString() [Fact] public void GetVariantAliases_MultipleEntries_ReturnsOneResultPerEntryWithUid() { - JObject full = ReadJsonRoot("variantsEntries.json"); - JArray entries = (JArray)full["entries"]; + JsonObject full = ReadJsonRoot("variantsEntries.json"); + JsonArray entries = full["entries"].AsArray(); const string contentTypeUid = "movie"; - JArray result = Utils.GetVariantAliases(entries, contentTypeUid); + JsonArray result = Utils.GetVariantAliases(entries, contentTypeUid); Assert.NotNull(result); Assert.Equal(3, result.Count); - JObject first = (JObject)result[0]; + JsonObject first = result[0].AsObject(); Assert.True(first["entry_uid"] != null && !string.IsNullOrEmpty(first["entry_uid"].ToString())); Assert.Equal(contentTypeUid, first["contenttype_uid"].ToString()); - var firstSet = JsonArrayToStringSet((JArray)first["variants"]); + var firstSet = JsonArrayToStringSet(first["variants"].AsArray()); Assert.Equal( new HashSet { "cs_personalize_0_0", "cs_personalize_0_3" }, firstSet); - JObject second = (JObject)result[1]; + JsonObject second = result[1].AsObject(); Assert.True(second["entry_uid"] != null && !string.IsNullOrEmpty(second["entry_uid"].ToString())); - Assert.Single((JArray)second["variants"]); - Assert.Equal("cs_personalize_0_0", ((JArray)second["variants"])[0].ToString()); + Assert.Single(second["variants"].AsArray()); + Assert.Equal("cs_personalize_0_0", second["variants"].AsArray()[0].ToString()); - JObject third = (JObject)result[2]; + JsonObject third = result[2].AsObject(); Assert.True(third["entry_uid"] != null && !string.IsNullOrEmpty(third["entry_uid"].ToString())); - Assert.Empty((JArray)third["variants"]); + Assert.Empty(third["variants"].AsArray()); } [Fact] public void GetVariantMetadataTags_MultipleEntries_ReturnsJsonArrayString() { - JObject full = ReadJsonRoot("variantsEntries.json"); - JArray entries = (JArray)full["entries"]; + JsonObject full = ReadJsonRoot("variantsEntries.json"); + JsonArray entries = full["entries"].AsArray(); const string contentTypeUid = "movie"; - JObject result = Utils.GetVariantMetadataTags(entries, contentTypeUid); + JsonObject result = Utils.GetVariantMetadataTags(entries, contentTypeUid); Assert.True(result["data-csvariants"] != null); string dataCsvariantsStr = result["data-csvariants"].ToString(); - JArray arr = JArray.Parse(dataCsvariantsStr); + JsonArray arr = JsonNode.Parse(dataCsvariantsStr).AsArray(); Assert.Equal(3, arr.Count); - Assert.True(((JObject)arr[0])["entry_uid"] != null && !string.IsNullOrEmpty(((JObject)arr[0])["entry_uid"].ToString())); - Assert.Equal(2, ((JArray)((JObject)arr[0])["variants"]).Count); - Assert.True(((JObject)arr[1])["entry_uid"] != null && !string.IsNullOrEmpty(((JObject)arr[1])["entry_uid"].ToString())); - Assert.Single((JArray)((JObject)arr[1])["variants"]); - Assert.True(((JObject)arr[2])["entry_uid"] != null && !string.IsNullOrEmpty(((JObject)arr[2])["entry_uid"].ToString())); - Assert.Empty((JArray)((JObject)arr[2])["variants"]); + Assert.True(arr[0].AsObject()["entry_uid"] != null && !string.IsNullOrEmpty(arr[0].AsObject()["entry_uid"].ToString())); + Assert.Equal(2, arr[0].AsObject()["variants"].AsArray().Count); + Assert.True(arr[1].AsObject()["entry_uid"] != null && !string.IsNullOrEmpty(arr[1].AsObject()["entry_uid"].ToString())); + Assert.Single(arr[1].AsObject()["variants"].AsArray()); + Assert.True(arr[2].AsObject()["entry_uid"] != null && !string.IsNullOrEmpty(arr[2].AsObject()["entry_uid"].ToString())); + Assert.Empty(arr[2].AsObject()["variants"].AsArray()); } [Fact] public void GetVariantAliases_ThrowsWhenEntryNull() { - Assert.Throws(() => Utils.GetVariantAliases((JObject)null, "landing_page")); + Assert.Throws(() => Utils.GetVariantAliases((JsonObject)null, "landing_page")); } [Fact] public void GetVariantAliases_ThrowsWhenContentTypeUidNull() { - JObject full = ReadJsonRoot("variantsSingleEntry.json"); - JObject entry = (JObject)full["entry"]; + JsonObject full = ReadJsonRoot("variantsSingleEntry.json"); + JsonObject entry = full["entry"].AsObject(); Assert.Throws(() => Utils.GetVariantAliases(entry, null)); } [Fact] public void GetVariantAliases_ThrowsWhenContentTypeUidEmpty() { - JObject full = ReadJsonRoot("variantsSingleEntry.json"); - JObject entry = (JObject)full["entry"]; + JsonObject full = ReadJsonRoot("variantsSingleEntry.json"); + JsonObject entry = full["entry"].AsObject(); Assert.Throws(() => Utils.GetVariantAliases(entry, "")); } [Fact] public void GetVariantMetadataTags_WhenEntryNull_ReturnsEmptyArrayString() { - JObject result = Utils.GetVariantMetadataTags((JObject)null, "landing_page"); + JsonObject result = Utils.GetVariantMetadataTags((JsonObject)null, "landing_page"); Assert.True(result["data-csvariants"] != null); Assert.Equal("[]", result["data-csvariants"].ToString()); } @@ -149,49 +149,49 @@ public void GetVariantMetadataTags_WhenEntryNull_ReturnsEmptyArrayString() [Fact] public void GetVariantAliases_ThrowsWhenUidMissing() { - var entry = new JObject { ["title"] = "no-uid" }; + var entry = new JsonObject { ["title"] = "no-uid" }; Assert.Throws(() => Utils.GetVariantAliases(entry, "movie")); } [Fact] public void GetVariantAliases_ThrowsWhenUidNull() { - var entry = new JObject { ["uid"] = JValue.CreateNull() }; + var entry = new JsonObject { ["uid"] = JsonNode.Parse("null") }; Assert.Throws(() => Utils.GetVariantAliases(entry, "movie")); } [Fact] public void GetVariantAliases_Batch_ThrowsWhenContentTypeUidNull() { - var entries = new JArray { new JObject { ["uid"] = "a" } }; + var entries = new JsonArray { new JsonObject { ["uid"] = "a" } }; Assert.Throws(() => Utils.GetVariantAliases(entries, null)); } [Fact] public void GetVariantAliases_Batch_ThrowsWhenContentTypeUidEmpty() { - var entries = new JArray { new JObject { ["uid"] = "a" } }; + var entries = new JsonArray { new JsonObject { ["uid"] = "a" } }; Assert.Throws(() => Utils.GetVariantAliases(entries, "")); } [Fact] public void GetVariantMetadataTags_WhenEntriesArrayNull_ReturnsEmptyArrayString() { - JObject result = Utils.GetVariantMetadataTags((JArray)null, "movie"); + JsonObject result = Utils.GetVariantMetadataTags((JsonArray)null, "movie"); Assert.Equal("[]", result["data-csvariants"].ToString()); } [Fact] public void GetVariantMetadataTags_Batch_ThrowsWhenContentTypeUidNull() { - var entries = new JArray { new JObject { ["uid"] = "a" } }; + var entries = new JsonArray { new JsonObject { ["uid"] = "a" } }; Assert.Throws(() => Utils.GetVariantMetadataTags(entries, null)); } [Fact] public void GetVariantMetadataTags_Batch_ThrowsWhenContentTypeUidEmpty() { - var entries = new JArray { new JObject { ["uid"] = "a" } }; + var entries = new JsonArray { new JsonObject { ["uid"] = "a" } }; Assert.Throws(() => Utils.GetVariantMetadataTags(entries, "")); } @@ -199,85 +199,85 @@ public void GetVariantMetadataTags_Batch_ThrowsWhenContentTypeUidEmpty() public void GetDataCsvariantsAttribute_DelegatesToGetVariantMetadataTags() { #pragma warning disable CS0618 // Type or member is obsolete — intentional coverage of backward-compatible alias - JObject full = ReadJsonRoot("variantsSingleEntry.json"); - JObject entry = (JObject)full["entry"]; + JsonObject full = ReadJsonRoot("variantsSingleEntry.json"); + JsonObject entry = full["entry"].AsObject(); const string contentTypeUid = "movie"; - JObject canonical = Utils.GetVariantMetadataTags(entry, contentTypeUid); - JObject legacy = Utils.GetDataCsvariantsAttribute(entry, contentTypeUid); - Assert.True(JToken.DeepEquals(canonical, legacy)); + JsonObject canonical = Utils.GetVariantMetadataTags(entry, contentTypeUid); + JsonObject legacy = Utils.GetDataCsvariantsAttribute(entry, contentTypeUid); + Assert.True(JsonNode.DeepEquals(canonical, legacy)); - JObject fullMulti = ReadJsonRoot("variantsEntries.json"); - JArray entries = (JArray)fullMulti["entries"]; - JObject canonicalBatch = Utils.GetVariantMetadataTags(entries, contentTypeUid); - JObject legacyBatch = Utils.GetDataCsvariantsAttribute(entries, contentTypeUid); - Assert.True(JToken.DeepEquals(canonicalBatch, legacyBatch)); + JsonObject fullMulti = ReadJsonRoot("variantsEntries.json"); + JsonArray entries = fullMulti["entries"].AsArray(); + JsonObject canonicalBatch = Utils.GetVariantMetadataTags(entries, contentTypeUid); + JsonObject legacyBatch = Utils.GetDataCsvariantsAttribute(entries, contentTypeUid); + Assert.True(JsonNode.DeepEquals(canonicalBatch, legacyBatch)); - JObject nullEntryLegacy = Utils.GetDataCsvariantsAttribute((JObject)null, "x"); - JObject nullEntryCanonical = Utils.GetVariantMetadataTags((JObject)null, "x"); - Assert.True(JToken.DeepEquals(nullEntryCanonical, nullEntryLegacy)); + JsonObject nullEntryLegacy = Utils.GetDataCsvariantsAttribute((JsonObject)null, "x"); + JsonObject nullEntryCanonical = Utils.GetVariantMetadataTags((JsonObject)null, "x"); + Assert.True(JsonNode.DeepEquals(nullEntryCanonical, nullEntryLegacy)); - JObject nullArrLegacy = Utils.GetDataCsvariantsAttribute((JArray)null, "x"); - JObject nullArrCanonical = Utils.GetVariantMetadataTags((JArray)null, "x"); - Assert.True(JToken.DeepEquals(nullArrCanonical, nullArrLegacy)); + JsonObject nullArrLegacy = Utils.GetDataCsvariantsAttribute((JsonArray)null, "x"); + JsonObject nullArrCanonical = Utils.GetVariantMetadataTags((JsonArray)null, "x"); + Assert.True(JsonNode.DeepEquals(nullArrCanonical, nullArrLegacy)); #pragma warning restore CS0618 } [Fact] public void GetVariantAliases_ReturnsEmptyVariantsWhenPublishDetailsMissing() { - var entry = new JObject { ["uid"] = "blt_no_pd" }; - JObject result = Utils.GetVariantAliases(entry, "movie"); + var entry = new JsonObject { ["uid"] = "blt_no_pd" }; + JsonObject result = Utils.GetVariantAliases(entry, "movie"); Assert.Equal("blt_no_pd", result["entry_uid"].ToString()); Assert.Equal("movie", result["contenttype_uid"].ToString()); - Assert.Empty((JArray)result["variants"]); + Assert.Empty(result["variants"].AsArray()); } [Fact] public void GetVariantAliases_ReturnsEmptyVariantsWhenVariantsObjectEmpty() { - var entry = new JObject + var entry = new JsonObject { ["uid"] = "blt_empty_v", - ["publish_details"] = new JObject + ["publish_details"] = new JsonObject { - ["variants"] = new JObject() + ["variants"] = new JsonObject() } }; - JObject result = Utils.GetVariantAliases(entry, "movie"); - Assert.Empty((JArray)result["variants"]); + JsonObject result = Utils.GetVariantAliases(entry, "movie"); + Assert.Empty(result["variants"].AsArray()); } [Fact] public void GetVariantAliases_ReturnsEmptyVariantsWhenVariantsKeyMissing() { - var entry = new JObject + var entry = new JsonObject { ["uid"] = "blt_no_variants_key", - ["publish_details"] = new JObject { ["time"] = "2025-01-01T00:00:00.000Z" } + ["publish_details"] = new JsonObject { ["time"] = "2025-01-01T00:00:00.000Z" } }; - JObject result = Utils.GetVariantAliases(entry, "movie"); - Assert.Empty((JArray)result["variants"]); + JsonObject result = Utils.GetVariantAliases(entry, "movie"); + Assert.Empty(result["variants"].AsArray()); } [Fact] public void GetVariantAliases_SkipsVariantWhenAliasMissingOrEmpty() { - var entry = new JObject + var entry = new JsonObject { ["uid"] = "blt_skip", - ["publish_details"] = new JObject + ["publish_details"] = new JsonObject { - ["variants"] = new JObject + ["variants"] = new JsonObject { - ["v1"] = new JObject { ["alias"] = "keep_me" }, - ["v2"] = new JObject(), - ["v3"] = new JObject { ["alias"] = "" } + ["v1"] = new JsonObject { ["alias"] = "keep_me" }, + ["v2"] = new JsonObject(), + ["v3"] = new JsonObject { ["alias"] = "" } } } }; - JObject result = Utils.GetVariantAliases(entry, "page"); - var variants = (JArray)result["variants"]; + JsonObject result = Utils.GetVariantAliases(entry, "page"); + var variants = result["variants"].AsArray(); Assert.Single(variants); Assert.Equal("keep_me", variants[0].ToString()); } diff --git a/Contentstack.Utils/Contentstack.Utils.csproj b/Contentstack.Utils/Contentstack.Utils.csproj index 6eaeb87..8fb1830 100644 --- a/Contentstack.Utils/Contentstack.Utils.csproj +++ b/Contentstack.Utils/Contentstack.Utils.csproj @@ -1,7 +1,8 @@ - netstandard2.0;net47;net472; + net10.0 + latest contentstack.utils Contentstack.Utils Contentstack @@ -37,9 +38,24 @@ + - + + + + + true + contentFiles/cs/any/Scripts/refresh-region.py + Content + false + + + true + build/contentstack.utils.targets + + + diff --git a/Contentstack.Utils/Converters/EmbeddedObjectConverter.cs b/Contentstack.Utils/Converters/EmbeddedObjectConverter.cs index e4d5ff3..9bb695f 100644 --- a/Contentstack.Utils/Converters/EmbeddedObjectConverter.cs +++ b/Contentstack.Utils/Converters/EmbeddedObjectConverter.cs @@ -1,33 +1,31 @@ using System; +using System.Text.Json; +using System.Text.Json.Serialization; using Contentstack.Utils.Interfaces; using Contentstack.Utils.Models; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace Contentstack.Utils.Converters { // Resolves IEmbeddedObject to EmbeddedObject during deserialization. - // CanConvert uses exact type match so customer-defined subclasses are not intercepted. - public class EmbeddedObjectConverter : JsonConverter + // JsonConverter is selected only for the bare IEmbeddedObject type + // (CanConvert matches typeof(IEmbeddedObject) exactly), so the concrete EmbeddedObject + // and customer-defined subclasses are not intercepted. + public class EmbeddedObjectConverter : JsonConverter { - public override bool CanConvert(Type objectType) - => objectType == typeof(IEmbeddedObject); - - public override bool CanWrite => false; - - public override object ReadJson(JsonReader reader, Type objectType, - object existingValue, JsonSerializer serializer) + public override IEmbeddedObject Read(ref Utf8JsonReader reader, Type typeToConvert, + JsonSerializerOptions options) { - if (reader.TokenType == JsonToken.Null) + if (reader.TokenType == JsonTokenType.Null) return null; - var jo = JObject.Load(reader); - var result = new EmbeddedObject(); - serializer.Populate(jo.CreateReader(), result); - return result; + // Deserialize into the concrete type. EmbeddedObject != IEmbeddedObject, so this + // converter is not re-entered and the default object converter handles the mapping + // (including [JsonExtensionData] for custom fields). + return JsonSerializer.Deserialize(ref reader, options); } - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, IEmbeddedObject value, + JsonSerializerOptions options) => throw new NotSupportedException( "EmbeddedObjectConverter is read-only. Serialize EmbeddedObject directly."); } diff --git a/Contentstack.Utils/Converters/NodeJsonConverter.cs b/Contentstack.Utils/Converters/NodeJsonConverter.cs index 88d22a7..2619eae 100644 --- a/Contentstack.Utils/Converters/NodeJsonConverter.cs +++ b/Contentstack.Utils/Converters/NodeJsonConverter.cs @@ -1,31 +1,91 @@ using System; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; using Contentstack.Utils.Models; namespace Contentstack.Utils.Converters { public class NodeJsonConverter : JsonConverter { - public override Node ReadJson(JsonReader reader, Type objectType, Node existingValue, bool hasExistingValue, JsonSerializer serializer) + public override Node Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - Node node = null; - JObject jObject = JObject.Load(reader); - if (jObject["type"] == null) + using (JsonDocument doc = JsonDocument.ParseValue(ref reader)) { - node = new TextNode(); - node.type = "text"; - }else - { - node = new Node(); + JsonElement root = doc.RootElement; + bool hasType = root.TryGetProperty("type", out JsonElement typeEl) && typeEl.ValueKind != JsonValueKind.Null; + + if (!hasType) + { + TextNode textNode = new TextNode(); + textNode.type = "text"; + PopulateTextNode(root, textNode, options); + return textNode; + } + + Node node = new Node(); + PopulateNode(root, node, options); + return node; } - serializer.Populate(jObject.CreateReader(), node); - return node; } - public override void WriteJson(JsonWriter writer, Node value, JsonSerializer serializer) + public override void Write(Utf8JsonWriter writer, Node value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value, value?.GetType() ?? typeof(Node), CloneWithoutNodeConverter(options)); + } + + private static void PopulateNode(JsonElement root, Node node, JsonSerializerOptions options) + { + if (root.TryGetProperty("type", out JsonElement typeProp)) + node.type = typeProp.GetString(); + + if (root.TryGetProperty("attrs", out JsonElement attrs)) + node.attrs = JsonSerializer.Deserialize>(attrs.GetRawText(), options); + + if (root.TryGetProperty("children", out JsonElement children)) + node.children = JsonSerializer.Deserialize>(children.GetRawText(), options); + } + + private static void PopulateTextNode(JsonElement root, TextNode node, JsonSerializerOptions options) + { + PopulateNode(root, node, options); + + if (root.TryGetProperty("text", out JsonElement text)) + node.text = text.GetString(); + + TryBindBool(root, "bold", v => node.bold = v); + TryBindBool(root, "italic", v => node.italic = v); + TryBindBool(root, "underline", v => node.underline = v); + TryBindBool(root, "strikethrough", v => node.strikethrough = v); + TryBindBool(root, "inlineCode", v => node.inlineCode = v); + TryBindBool(root, "subscript", v => node.subscript = v); + TryBindBool(root, "superscript", v => node.superscript = v); + + if (root.TryGetProperty("classname", out JsonElement cn)) + node.classname = cn.ValueKind == JsonValueKind.Null ? null : cn.GetString(); + + if (root.TryGetProperty("id", out JsonElement id)) + node.id = id.ValueKind == JsonValueKind.Null ? null : id.GetString(); + } + + private static void TryBindBool(JsonElement root, string name, Action set) { - + if (!root.TryGetProperty(name, out JsonElement el)) + return; + if (el.ValueKind == JsonValueKind.True || el.ValueKind == JsonValueKind.False) + set(el.GetBoolean()); + } + + private static JsonSerializerOptions CloneWithoutNodeConverter(JsonSerializerOptions options) + { + JsonSerializerOptions inner = new JsonSerializerOptions(options); + for (int i = inner.Converters.Count - 1; i >= 0; i--) + { + if (inner.Converters[i] is NodeJsonConverter) + inner.Converters.RemoveAt(i); + } + + return inner; } } } diff --git a/Contentstack.Utils/Converters/RTEJsonConverter.cs b/Contentstack.Utils/Converters/RTEJsonConverter.cs index ecc198b..6b57279 100644 --- a/Contentstack.Utils/Converters/RTEJsonConverter.cs +++ b/Contentstack.Utils/Converters/RTEJsonConverter.cs @@ -1,50 +1,169 @@ using System; using System.Linq; using System.Reflection; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using Contentstack.Utils.Constants; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Contentstack.Utils.Interfaces; namespace Contentstack.Utils.Converters { - public class RTEJsonConverter : JsonConverter + /// + /// Factory for JSON path-based deserialization used by and . + /// + public sealed class RTEJsonConverterFactory : JsonConverterFactory { - public override bool CanConvert(Type objectType) + public override bool CanConvert(Type typeToConvert) { - throw new InvalidOperationException(ErrorMessages.InvalidRteJson); + if (!typeToConvert.IsGenericType) + return false; + + Type def = typeToConvert.GetGenericTypeDefinition(); + return def == typeof(Models.JsonRTENode<>) || def == typeof(Models.JsonRTENodes<>); } - public override object ReadJson(JsonReader reader, Type objectType, - object existingValue, JsonSerializer serializer) + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - JObject jo = JObject.Load(reader); - object targetObj = Activator.CreateInstance(objectType); + Type embeddedArg = typeToConvert.GetGenericArguments()[0]; + Type def = typeToConvert.GetGenericTypeDefinition(); + + if (def == typeof(Models.JsonRTENode<>)) + { + Type converterType = typeof(RTEJsonConverterImpl<>).MakeGenericType(embeddedArg); + return (JsonConverter)Activator.CreateInstance(converterType); + } - foreach (PropertyInfo prop in objectType.GetProperties() - .Where(p => p.CanRead && p.CanWrite)) + if (def == typeof(Models.JsonRTENodes<>)) { - JsonPropertyAttribute att = prop.GetCustomAttributes(true) - .OfType() - .FirstOrDefault(); + Type converterType = typeof(RTEJsonNodesConverterImpl<>).MakeGenericType(embeddedArg); + return (JsonConverter)Activator.CreateInstance(converterType); + } - string jsonPath = (att != null ? att.PropertyName : prop.Name); - JToken token = jo.SelectToken(jsonPath); + throw new InvalidOperationException($"Unsupported type for RTE JSON converter: {typeToConvert}"); + } + } - if (token != null && token.Type != JTokenType.Null) + /// + /// Deserializes objects whose properties map to dotted JSON paths (same idea as Newtonsoft SelectToken with ). + /// + public sealed class PathMappedJsonConverter : JsonConverter + { + public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + JsonObject jo = JsonNode.Parse(ref reader) as JsonObject ?? new JsonObject(); + T targetObj = Activator.CreateInstance(); + + foreach (PropertyInfo prop in typeof(T).GetProperties().Where(p => p.CanRead && p.CanWrite)) + { + JsonPropertyNameAttribute att = prop.GetCustomAttribute(); + string jsonPath = att?.Name ?? prop.Name; + JsonNode token = RTEJsonPath.SelectPath(jo, jsonPath); + + if (token != null && !RTEJsonPath.IsJsonNull(token)) { - object value = token.ToObject(prop.PropertyType, serializer); - prop.SetValue(targetObj, value, null); + object value = RTEJsonPath.DeserializeNode(token, prop.PropertyType, options); + prop.SetValue(targetObj, value); } } return targetObj; } + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + throw new NotSupportedException("Serialization is not supported for path-mapped RTE models."); + } + } - public override void WriteJson(JsonWriter writer, object value, - JsonSerializer serializer) + internal static class RTEJsonPath + { + internal static JsonNode SelectPath(JsonObject root, string path) + { + if (root == null || string.IsNullOrEmpty(path)) + return null; + + string[] segments = path.Split('.'); + JsonNode current = root; + foreach (string segment in segments) + { + if (current is JsonObject jo && jo.TryGetPropertyValue(segment, out JsonNode next)) + current = next; + else + return null; + } + + return current; + } + + internal static bool IsJsonNull(JsonNode node) { + if (node == null) + return true; + return node is JsonValue jv && jv.GetValueKind() == JsonValueKind.Null; + } + internal static object DeserializeNode(JsonNode token, Type propertyType, JsonSerializerOptions options) + { + string json = token.ToJsonString(options); + return JsonSerializer.Deserialize(json, propertyType, options); + } + } + + internal sealed class RTEJsonConverterImpl : JsonConverter> where T : IEmbeddedObject + { + public override Models.JsonRTENode Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + JsonObject jo = JsonNode.Parse(ref reader) as JsonObject ?? new JsonObject(); + var targetObj = new Models.JsonRTENode(); + + foreach (PropertyInfo prop in typeof(Models.JsonRTENode).GetProperties().Where(p => p.CanRead && p.CanWrite)) + { + JsonPropertyNameAttribute att = prop.GetCustomAttribute(); + string jsonPath = att?.Name ?? prop.Name; + JsonNode token = RTEJsonPath.SelectPath(jo, jsonPath); + + if (token != null && !RTEJsonPath.IsJsonNull(token)) + { + object value = RTEJsonPath.DeserializeNode(token, prop.PropertyType, options); + prop.SetValue(targetObj, value); + } + } + + return targetObj; + } + + public override void Write(Utf8JsonWriter writer, Models.JsonRTENode value, JsonSerializerOptions options) + { + throw new NotSupportedException("Serialization is not supported for JsonRTENode."); + } + } + + internal sealed class RTEJsonNodesConverterImpl : JsonConverter> where T : IEmbeddedObject + { + public override Models.JsonRTENodes Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + JsonObject jo = JsonNode.Parse(ref reader) as JsonObject ?? new JsonObject(); + var targetObj = new Models.JsonRTENodes(); + + foreach (PropertyInfo prop in typeof(Models.JsonRTENodes).GetProperties().Where(p => p.CanRead && p.CanWrite)) + { + JsonPropertyNameAttribute att = prop.GetCustomAttribute(); + string jsonPath = att?.Name ?? prop.Name; + JsonNode token = RTEJsonPath.SelectPath(jo, jsonPath); + + if (token != null && !RTEJsonPath.IsJsonNull(token)) + { + object val = RTEJsonPath.DeserializeNode(token, prop.PropertyType, options); + prop.SetValue(targetObj, val); + } + } + + return targetObj; + } + + public override void Write(Utf8JsonWriter writer, Models.JsonRTENodes value, JsonSerializerOptions options) + { + throw new NotSupportedException("Serialization is not supported for JsonRTENodes."); } } } diff --git a/Contentstack.Utils/Endpoints/Endpoint.cs b/Contentstack.Utils/Endpoints/Endpoint.cs new file mode 100644 index 0000000..3e99f2f --- /dev/null +++ b/Contentstack.Utils/Endpoints/Endpoint.cs @@ -0,0 +1,287 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Contentstack.Utils.Endpoints +{ + /// + /// Resolves Contentstack service URLs for any supported region. + /// + /// All public methods are static — no instantiation required. + /// + /// Example: + /// + /// // Full URL + /// string url = Endpoint.GetContentstackEndpoint("na", "contentDelivery"); + /// // → "https://cdn.contentstack.io" + /// + /// // Host only (omit https://) — useful for SDK host configuration + /// string host = Endpoint.GetContentstackEndpoint("eu", "contentDelivery", omitHttps: true); + /// // → "eu-cdn.contentstack.com" + /// + /// // All endpoints for a region + /// Dictionary<string, string> all = Endpoint.GetContentstackEndpoint("azure-na"); + /// // → { "contentDelivery": "...", "contentManagement": "...", ... } + /// + /// + public static class Endpoint + { + private const string RegionsUrl = "https://artifacts.contentstack.com/regions.json"; + + // Module-level cache — loaded once per process, shared across all calls. + private static JsonElement[]? _regionsData; + private static readonly object _cacheLock = new object(); + private static readonly HttpClient _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + + /// + /// Resolves a single Contentstack service endpoint URL for the given region. + /// + /// + /// Region ID or alias (case-insensitive). Examples: "na", "us", "eu", "AWS-NA", "azure_eu", "gcp-na". + /// + /// + /// Service key. Valid keys include: "contentDelivery", "contentManagement", "auth", + /// "graphqlDelivery", "preview", "images", "assets", "automate", "launch", + /// "developerHub", "brandKit", "genAI", "personalizeManagement", + /// "personalizeEdge", "composableStudio", "assetManagement". + /// + /// + /// When true, strips the https:// scheme from the returned URL. + /// Useful when passing the host to an SDK that constructs its own URLs. + /// + /// The endpoint URL string for the specified service. + /// If region is empty or whitespace. + /// If region or service is not found. + /// If the registry cannot be read or is corrupt. + public static string GetContentstackEndpoint(string region, string service, bool omitHttps = false) + { + if (string.IsNullOrWhiteSpace(region)) + throw new ArgumentException("Empty region provided. Please put valid region.", nameof(region)); + + var regions = LoadRegions(); + string normalized = region.Trim().ToLowerInvariant(); + var regionEl = FindRegion(regions, normalized); + + if (regionEl == null) + throw new KeyNotFoundException($"Invalid region: {region}"); + + var endpoints = regionEl.Value.GetProperty("endpoints"); + if (!endpoints.TryGetProperty(service, out var urlEl)) + { + string regionId = regionEl.Value.GetProperty("id").GetString()!; + throw new KeyNotFoundException($"Service \"{service}\" not found for region \"{regionId}\""); + } + + string url = urlEl.GetString()!; + return omitHttps ? StripHttps(url) : url; + } + + /// + /// Returns all service endpoint URLs for the given region. + /// + /// Region ID or alias (case-insensitive). + /// When true, strips the https:// scheme from all returned URLs. + /// Dictionary mapping service keys to endpoint URLs. + /// If region is empty or whitespace. + /// If region is not found. + /// If the registry cannot be read or is corrupt. + public static Dictionary GetContentstackEndpoint(string region, bool omitHttps = false) + { + if (string.IsNullOrWhiteSpace(region)) + throw new ArgumentException("Empty region provided. Please put valid region.", nameof(region)); + + var regions = LoadRegions(); + string normalized = region.Trim().ToLowerInvariant(); + var regionEl = FindRegion(regions, normalized); + + if (regionEl == null) + throw new KeyNotFoundException($"Invalid region: {region}"); + + var result = new Dictionary(); + var endpoints = regionEl.Value.GetProperty("endpoints"); + foreach (var ep in endpoints.EnumerateObject()) + { + string url = ep.Value.GetString()!; + result[ep.Name] = omitHttps ? StripHttps(url) : url; + } + return result; + } + + /// + /// Clears the in-memory region cache. Intended for testing only — forces the + /// next call to re-read regions.json from disk or re-download from CDN. + /// + public static void ResetCache() + { + lock (_cacheLock) + { + _regionsData = null; + } + } + + // ------------------------------------------------------------------ + // Internal helpers + // ------------------------------------------------------------------ + + /// + /// Load and cache the regions registry. + /// + /// Resolution order: + /// 1. In-memory cache — zero I/O after the first call in a process + /// 2. Local file on disk — Assets/regions.json next to the DLL + /// (written by DownloadAndSave or refresh-region.cs) + /// 3. CDN download — fetches from artifacts.contentstack.com, + /// writes to disk for future calls (silent on failure) + /// + private static JsonElement[] LoadRegions() + { + lock (_cacheLock) + { + if (_regionsData != null) + return _regionsData; + + string localFile = GetLocalFilePath(); + + // Step 2 — local file on disk + string? json = ReadLocalFile(localFile); + + // Step 3 — CDN download, writes to disk so next startup skips this step + if (json == null) + json = DownloadAndSave(localFile); + + if (json == null) + throw new InvalidOperationException( + "contentstack_utils: regions.json not found and could not be downloaded. " + + "Run 'dotnet run Scripts/refresh-region.cs' and ensure network access."); + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + "contentstack_utils: regions.json is corrupt. " + + "Run 'dotnet run Scripts/refresh-region.cs' to re-download it.", ex); + } + + if (!doc.RootElement.TryGetProperty("regions", out var regionsEl) || + regionsEl.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + "contentstack_utils: regions.json is corrupt. " + + "Run 'dotnet run Scripts/refresh-region.cs' to re-download it."); + } + + var list = new List(); + foreach (var r in regionsEl.EnumerateArray()) + list.Add(r.Clone()); + + _regionsData = list.ToArray(); + return _regionsData; + } + } + + /// + /// Returns the path to regions.json next to the DLL + /// + internal static string GetLocalFilePath() + { + string assemblyDir = Path.GetDirectoryName(typeof(Endpoint).Assembly.Location) + ?? AppContext.BaseDirectory; + return Path.Combine(assemblyDir, "Assets", "regions.json"); + } + + /// + /// Reads regions.json from disk. Returns null if the file does not exist. + /// + private static string? ReadLocalFile(string path) + { + if (!File.Exists(path)) + return null; + try + { + return File.ReadAllText(path); + } + catch + { + return null; + } + } + + /// + /// Downloads regions.json from the CDN and writes it to disk so that future + /// process startups read from the local file instead of downloading again. + /// Silent on all failures (network error, permission denied). + /// + private static string? DownloadAndSave(string dest) + { + try + { + var task = _httpClient.GetStringAsync(RegionsUrl); + task.Wait(); + string data = task.Result; + + using var doc = JsonDocument.Parse(data); + if (!doc.RootElement.TryGetProperty("regions", out _)) + return null; + + // Write to disk — next startup reads from local file (Step 2). + // Silent on PermissionError or read-only filesystem + try + { + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + File.WriteAllText(dest, data); + } + catch { } + + return data; + } + catch + { + return null; + } + } + + /// + /// Two-pass region lookup: ID match wins over alias match. + /// Input must already be lowercased and trimmed. + /// + private static JsonElement? FindRegion(JsonElement[] regions, string normalized) + { + // Pass 1 — exact id match + foreach (var r in regions) + { + if (r.TryGetProperty("id", out var id) && + id.GetString()?.ToLowerInvariant() == normalized) + return r; + } + + // Pass 2 — alias match + foreach (var r in regions) + { + if (!r.TryGetProperty("alias", out var aliases)) continue; + foreach (var alias in aliases.EnumerateArray()) + { + if (alias.GetString()?.ToLowerInvariant() == normalized) + return r; + } + } + + return null; + } + + private static string StripHttps(string url) + { + return Regex.Replace(url, @"^https?://", string.Empty); + } + } +} diff --git a/Contentstack.Utils/Interfaces/IEdges.cs b/Contentstack.Utils/Interfaces/IEdges.cs index a127b6b..bd0ccce 100644 --- a/Contentstack.Utils/Interfaces/IEdges.cs +++ b/Contentstack.Utils/Interfaces/IEdges.cs @@ -1,11 +1,11 @@ using System; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Contentstack.Utils.Interfaces { public class IEdges where T: IEmbeddedObject { - [JsonProperty("node")] + [JsonPropertyName("node")] public T Node { get; diff --git a/Contentstack.Utils/JsonAttrValue.cs b/Contentstack.Utils/JsonAttrValue.cs new file mode 100644 index 0000000..477239b --- /dev/null +++ b/Contentstack.Utils/JsonAttrValue.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Contentstack.Utils +{ + /// + /// Newtonsoft.Json deserialized scalar attributes as . + /// System.Text.Json stores dynamic values as — unwrap for the same runtime behavior. + /// + internal static class JsonAttrValue + { + internal static string AsString(object value) + { + if (value == null) + return null; + + if (value is string s) + return s; + + if (value is JsonElement je) + { + switch (je.ValueKind) + { + case JsonValueKind.String: + return je.GetString(); + case JsonValueKind.Number: + return je.GetRawText(); + case JsonValueKind.True: + return "true"; + case JsonValueKind.False: + return "false"; + case JsonValueKind.Null: + return null; + default: + return je.ToString(); + } + } + + return Convert.ToString(value); + } + + internal static bool TryGetStyleObject(object styleVal, out Dictionary styles) + { + styles = null; + if (styleVal == null) + return false; + + if (styleVal is JsonObject jo) + { + styles = JsonSerializer.Deserialize>(jo.ToJsonString()); + return styles != null; + } + + if (styleVal is JsonElement je && je.ValueKind == JsonValueKind.Object) + { + styles = JsonSerializer.Deserialize>(je.GetRawText()); + return styles != null; + } + + return false; + } + } +} diff --git a/Contentstack.Utils/Models/EmbeddedObject.cs b/Contentstack.Utils/Models/EmbeddedObject.cs index 9e2fd1a..48a2041 100644 --- a/Contentstack.Utils/Models/EmbeddedObject.cs +++ b/Contentstack.Utils/Models/EmbeddedObject.cs @@ -1,30 +1,30 @@ using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; using Contentstack.Utils.Interfaces; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; namespace Contentstack.Utils.Models { // Concrete class used by EmbeddedObjectConverter when deserializing _embedded_items. // Implements both IEmbeddedEntry and IEmbeddedAsset to cover entries and assets. public class EmbeddedObject : IEmbeddedEntry, IEmbeddedAsset { - [JsonProperty("uid")] + [JsonPropertyName("uid")] public string Uid { get; set; } = string.Empty; - [JsonProperty("_content_type_uid")] + [JsonPropertyName("_content_type_uid")] public string ContentTypeUid { get; set; } = string.Empty; - [JsonProperty("title")] + [JsonPropertyName("title")] public string Title { get; set; } = string.Empty; - [JsonProperty("filename")] + [JsonPropertyName("filename")] public string FileName { get; set; } = string.Empty; - [JsonProperty("url")] + [JsonPropertyName("url")] public string Url { get; set; } = string.Empty; // Any field not explicitly declared above (custom fields, locale data, etc.) [JsonExtensionData] - public IDictionary Fields { get; set; } = new Dictionary(); + public Dictionary Fields { get; set; } = new Dictionary(); } } diff --git a/Contentstack.Utils/Models/JsonRTENode.cs b/Contentstack.Utils/Models/JsonRTENode.cs index b69a1bc..068f573 100644 --- a/Contentstack.Utils/Models/JsonRTENode.cs +++ b/Contentstack.Utils/Models/JsonRTENode.cs @@ -1,17 +1,16 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using Contentstack.Utils.Converters; using Contentstack.Utils.Interfaces; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Contentstack.Utils.Models { - [Newtonsoft.Json.JsonConverter(typeof(RTEJsonConverter))] - public class JsonRTENode where T: IEmbeddedObject + [JsonConverter(typeof(RTEJsonConverterFactory))] + public class JsonRTENode where T : IEmbeddedObject { - [JsonProperty("json")] + [JsonPropertyName("json")] public Node Json { get; set; } - [JsonProperty("embedded_itemsConnection.edges")] + [JsonPropertyName("embedded_itemsConnection.edges")] public List> Edges { get; set; } } } diff --git a/Contentstack.Utils/Models/JsonRTENodes.cs b/Contentstack.Utils/Models/JsonRTENodes.cs index 31dbfcd..538115e 100644 --- a/Contentstack.Utils/Models/JsonRTENodes.cs +++ b/Contentstack.Utils/Models/JsonRTENodes.cs @@ -1,17 +1,16 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using Contentstack.Utils.Converters; using Contentstack.Utils.Interfaces; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Contentstack.Utils.Models { - [Newtonsoft.Json.JsonConverter(typeof(RTEJsonConverter))] + [JsonConverter(typeof(RTEJsonConverterFactory))] public class JsonRTENodes where T : IEmbeddedObject { - [JsonProperty("json")] + [JsonPropertyName("json")] public List Json { get; set; } - [JsonProperty("embedded_itemsConnection.edges")] + [JsonPropertyName("embedded_itemsConnection.edges")] public List> Edges { get; set; } } } diff --git a/Contentstack.Utils/Models/Metadata.cs b/Contentstack.Utils/Models/Metadata.cs index e87ee17..d29e385 100644 --- a/Contentstack.Utils/Models/Metadata.cs +++ b/Contentstack.Utils/Models/Metadata.cs @@ -1,5 +1,6 @@ -using System; +using System; using System.Runtime.CompilerServices; +using Contentstack.Utils; using Contentstack.Utils.Enums; using HtmlAgilityPack; @@ -71,13 +72,13 @@ public static implicit operator Metadata(HtmlNode node) public static implicit operator Metadata(Node node) { StyleType styleType; - if (!node.attrs.ContainsKey("display-type") || !(Enum.TryParse((string)node.attrs["display-type"], true, out styleType))) + if (!node.attrs.ContainsKey("display-type") || !(Enum.TryParse(JsonAttrValue.AsString(node.attrs["display-type"]), true, out styleType))) { styleType = StyleType.Block; } EmbedItemType embedItemType; - if (!node.attrs.ContainsKey("type") || !(Enum.TryParse((string)node.attrs["type"], true, out embedItemType))) + if (!node.attrs.ContainsKey("type") || !(Enum.TryParse(JsonAttrValue.AsString(node.attrs["type"]), true, out embedItemType))) { embedItemType = EmbedItemType.Entry; } @@ -89,10 +90,10 @@ public static implicit operator Metadata(Node node) string itemUID = ""; if (node.attrs.ContainsKey("entry-uid")) { - itemUID = (string)node.attrs["entry-uid"]; + itemUID = JsonAttrValue.AsString(node.attrs["entry-uid"]); }else if (node.attrs.ContainsKey("asset-uid")) { - itemUID = (string)node.attrs["asset-uid"]; + itemUID = JsonAttrValue.AsString(node.attrs["asset-uid"]); } return new Metadata() @@ -102,7 +103,7 @@ public static implicit operator Metadata(Node node) StyleType = styleType, ItemType = embedItemType, ItemUid = itemUID, - ContentTypeUid = node.attrs.ContainsKey("content-type-uid") ? (string)node.attrs["content-type-uid"] : "", + ContentTypeUid = node.attrs.ContainsKey("content-type-uid") ? JsonAttrValue.AsString(node.attrs["content-type-uid"]) : "", attributes = node.attrs }; diff --git a/Contentstack.Utils/Models/Node.cs b/Contentstack.Utils/Models/Node.cs index b64a70e..ba7e659 100644 --- a/Contentstack.Utils/Models/Node.cs +++ b/Contentstack.Utils/Models/Node.cs @@ -1,9 +1,6 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using Contentstack.Utils.Converters; -using Contentstack.Utils.Enums; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json.Serialization; namespace Contentstack.Utils.Models { diff --git a/Contentstack.Utils/Models/Options.cs b/Contentstack.Utils/Models/Options.cs index 724322a..d8433a6 100644 --- a/Contentstack.Utils/Models/Options.cs +++ b/Contentstack.Utils/Models/Options.cs @@ -2,7 +2,8 @@ using System.Collections.Generic; using Contentstack.Utils.Enums; using Contentstack.Utils.Interfaces; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using Contentstack.Utils; namespace Contentstack.Utils.Models { @@ -105,14 +106,12 @@ public virtual string RenderNode(string nodeType, Node node, NodeChildrenCallBac var styleVal = node.attrs["style"]; if (styleVal != null) { - if (styleVal is string) + if (styleVal is string styleStr) { - styleAttrs = $" style=\"{styleVal}\""; + styleAttrs = $" style=\"{styleStr}\""; } - else if (styleVal is JObject) + else if (JsonAttrValue.TryGetStyleObject(styleVal, out Dictionary styleDictionary) && styleDictionary != null) { - var styleObject = (JObject)styleVal; - var styleDictionary = styleObject.ToObject>(); styleAttrs = " style=\""; foreach (var pair in styleDictionary) { @@ -129,28 +128,28 @@ public virtual string RenderNode(string nodeType, Node node, NodeChildrenCallBac case "a": if (node.attrs?.ContainsKey("url")==true) { - href = (string)node.attrs["url"]; + href = JsonAttrValue.AsString(node.attrs["url"]); } if (node.attrs?.ContainsKey("target") == true) { - target = (string)node.attrs["target"]; + target = JsonAttrValue.AsString(node.attrs["target"]); } if (node.attrs?.ContainsKey("title") == true) { - title = (string)node.attrs["title"]; + title = JsonAttrValue.AsString(node.attrs["title"]); } return $"{callBack(node.children)}"; case "img": if (node.attrs.ContainsKey("url")) { - href = (string)node.attrs["url"]; + href = JsonAttrValue.AsString(node.attrs["url"]); } return $"{callBack(node.children)}"; case "embed": if (node.attrs.ContainsKey("url")) { - href = (string)node.attrs["url"]; + href = JsonAttrValue.AsString(node.attrs["url"]); } return $"{callBack(node.children)}"; case "fragment": diff --git a/Contentstack.Utils/Utils.cs b/Contentstack.Utils/Utils.cs index 090e81d..15c84d5 100644 --- a/Contentstack.Utils/Utils.cs +++ b/Contentstack.Utils/Utils.cs @@ -6,8 +6,8 @@ using Contentstack.Utils.Extensions; using Contentstack.Utils.Interfaces; using Contentstack.Utils.Enums; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; namespace Contentstack.Utils { @@ -703,7 +703,16 @@ private static object GetParentTagsValue(string dataValue, bool tagsAsObject) } } - public static JObject GetVariantAliases(JObject entry, string contentTypeUid) + private static readonly JsonSerializerOptions CompactJsonOptions = new JsonSerializerOptions { WriteIndented = false }; + + private static bool IsJsonNull(JsonNode node) + { + if (node == null) + return true; + return node is JsonValue jv && jv.GetValueKind() == JsonValueKind.Null; + } + + public static JsonObject GetVariantAliases(JsonObject entry, string contentTypeUid) { if (string.IsNullOrEmpty(contentTypeUid)) { @@ -713,14 +722,14 @@ public static JObject GetVariantAliases(JObject entry, string contentTypeUid) { throw new ArgumentException("Entry must not be null."); } - if (!entry.ContainsKey("uid") || entry["uid"] == null || entry["uid"].Type == JTokenType.Null) + if (!entry.TryGetPropertyValue("uid", out JsonNode uidNode) || IsJsonNull(uidNode)) { throw new ArgumentException("Entry must contain uid."); } - string entryUid = entry["uid"]?.ToString() ?? ""; - JArray variantsArray = ExtractVariantAliasesFromEntry(entry); - JObject result = new JObject + string entryUid = uidNode?.ToString() ?? ""; + JsonArray variantsArray = ExtractVariantAliasesFromEntry(entry); + var result = new JsonObject { ["entry_uid"] = entryUid, ["contenttype_uid"] = contentTypeUid, @@ -729,7 +738,7 @@ public static JObject GetVariantAliases(JObject entry, string contentTypeUid) return result; } - public static JArray GetVariantAliases(JArray entries, string contentTypeUid) + public static JsonArray GetVariantAliases(JsonArray entries, string contentTypeUid) { if (string.IsNullOrEmpty(contentTypeUid)) { @@ -737,13 +746,15 @@ public static JArray GetVariantAliases(JArray entries, string contentTypeUid) } if (entries == null) { - return new JArray(); + return new JsonArray(); } - JArray variantResults = new JArray(); - foreach (JToken token in entries) + var variantResults = new JsonArray(); + foreach (JsonNode token in entries) { - JObject entry = token as JObject; - if (entry != null && entry.ContainsKey("uid") && entry["uid"] != null && entry["uid"].Type != JTokenType.Null) + if (token is JsonObject entry && + entry.TryGetPropertyValue("uid", out JsonNode uidNode) && + uidNode != null && + !IsJsonNull(uidNode)) { variantResults.Add(GetVariantAliases(entry, contentTypeUid)); } @@ -756,17 +767,17 @@ public static JArray GetVariantAliases(JArray entries, string contentTypeUid) /// /// Entry JSON (e.g. from the Delivery API), or null to produce an empty payload. /// Content type UID for the entry. - /// A with a data-csvariants key whose value is a compact JSON array string. - public static JObject GetVariantMetadataTags(JObject entry, string contentTypeUid) + /// A with a data-csvariants key whose value is a compact JSON array string. + public static JsonObject GetVariantMetadataTags(JsonObject entry, string contentTypeUid) { if (entry == null) { - JObject result = new JObject(); - result["data-csvariants"] = "[]"; - return result; + var empty = new JsonObject(); + empty["data-csvariants"] = "[]"; + return empty; } - JArray entries = new JArray(); - entries.Add(entry); + var entries = new JsonArray(); + entries.Add(entry.DeepClone()); return GetVariantMetadataTags(entries, contentTypeUid); } @@ -775,10 +786,10 @@ public static JObject GetVariantMetadataTags(JObject entry, string contentTypeUi /// /// Array of entry JSON objects, or null to produce an empty payload. /// Content type UID shared by these entries. - /// A with a data-csvariants key whose value is a compact JSON array string. - public static JObject GetVariantMetadataTags(JArray entries, string contentTypeUid) + /// A with a data-csvariants key whose value is a compact JSON array string. + public static JsonObject GetVariantMetadataTags(JsonArray entries, string contentTypeUid) { - JObject result = new JObject(); + var result = new JsonObject(); if (entries == null) { result["data-csvariants"] = "[]"; @@ -789,46 +800,44 @@ public static JObject GetVariantMetadataTags(JArray entries, string contentTypeU throw new ArgumentException("ContentType is required."); } - JArray variantResults = GetVariantAliases(entries, contentTypeUid); - result["data-csvariants"] = variantResults.ToString(Formatting.None); + JsonArray variantResults = GetVariantAliases(entries, contentTypeUid); + result["data-csvariants"] = variantResults.ToJsonString(CompactJsonOptions); return result; } /// - /// Prefer . This alias exists for backward compatibility and will be removed in a future major release. + /// Prefer . This alias exists for backward compatibility and will be removed in a future major release. /// [Obsolete("Use GetVariantMetadataTags instead. This method will be removed in a future major release.")] - public static JObject GetDataCsvariantsAttribute(JObject entry, string contentTypeUid) + public static JsonObject GetDataCsvariantsAttribute(JsonObject entry, string contentTypeUid) { return GetVariantMetadataTags(entry, contentTypeUid); } /// - /// Prefer . This alias exists for backward compatibility and will be removed in a future major release. + /// Prefer . This alias exists for backward compatibility and will be removed in a future major release. /// [Obsolete("Use GetVariantMetadataTags instead. This method will be removed in a future major release.")] - public static JObject GetDataCsvariantsAttribute(JArray entries, string contentTypeUid) + public static JsonObject GetDataCsvariantsAttribute(JsonArray entries, string contentTypeUid) { return GetVariantMetadataTags(entries, contentTypeUid); } - private static JArray ExtractVariantAliasesFromEntry(JObject entry) + private static JsonArray ExtractVariantAliasesFromEntry(JsonObject entry) { - JArray variantArray = new JArray(); - JObject publishDetails = entry["publish_details"] as JObject; - if (publishDetails == null) + var variantArray = new JsonArray(); + if (!entry.TryGetPropertyValue("publish_details", out JsonNode pdNode) || pdNode is not JsonObject publishDetails) { return variantArray; } - JObject variants = publishDetails["variants"] as JObject; - if (variants == null) + if (!publishDetails.TryGetPropertyValue("variants", out JsonNode vNode) || vNode is not JsonObject variants) { return variantArray; } - foreach (JProperty prop in variants.Properties()) + foreach (KeyValuePair kvp in variants) { - if (prop.Value is JObject valueObj) + if (kvp.Value is JsonObject valueObj) { string alias = valueObj["alias"]?.ToString(); if (!string.IsNullOrEmpty(alias)) @@ -839,5 +848,19 @@ private static JArray ExtractVariantAliasesFromEntry(JObject entry) } return variantArray; } + + /// + /// Resolves a single Contentstack service endpoint URL for the given region. + /// Proxy for . + /// + public static string GetContentstackEndpoint(string region, string service, bool omitHttps = false) + => Endpoints.Endpoint.GetContentstackEndpoint(region, service, omitHttps); + + /// + /// Returns all service endpoint URLs for the given region. + /// Proxy for . + /// + public static Dictionary GetContentstackEndpoint(string region, bool omitHttps = false) + => Endpoints.Endpoint.GetContentstackEndpoint(region, omitHttps); } } diff --git a/Directory.Build.props b/Directory.Build.props index b42094c..70bd695 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 1.4.0 + 2.0.0 diff --git a/README.md b/README.md index ae079d5..8abe9c6 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,10 @@ This guide will help you get started with Contentstack .NET Utils SDK to build a ## Prerequisites To get started with .NET, you will need the following: -- .NET version 3.1 or later - +- .NET 10 or later + +> **Migrating from v1.x?** Version 2.0.0 replaces Newtonsoft.Json with System.Text.Json and changes several public method signatures. See the [migration guide](https://www.contentstack.com/docs/developers/sdks/utils-sdk/dot-net/migrate-dotnet-utils-sdk-from-newtonsoft-to-stj) before upgrading. + ## Setup and Installation Note: If you are using Contentstack .NET SDK, you don’t need to download the Contentstack.Utils package separately as it will be already available for use. @@ -146,6 +148,36 @@ CustomRenderOption defaultRender = new CustomRenderOption(entry); ``` > Note: Make sure the entry parameter has implemented the IEmbeddedObject property. +> **Note:** As of v2.0.0, `Node.attrs` values are `System.Text.Json.JsonElement` instead of `Newtonsoft.Json.Linq.JToken`. If you access `attrs` directly, update the accessor: +> ```c# +> // Before (v1.x) +> string src = node.attrs["src"].Value(); +> // After (v2.0.0) +> string src = node.attrs["src"].GetString(); +> ``` + +## Variant Utility + +Use the `Utils.GetVariantAliases` and `Utils.GetVariantMetadataTags` methods to extract variant alias information from an entry and build the `data-csvariants` attribute payload used for [Personalize](https://www.contentstack.com/docs/personalize) variants. As of v2.0.0, these methods accept `System.Text.Json.Nodes.JsonObject` / `JsonArray` instead of `Newtonsoft.Json.Linq.JObject` / `JArray`. + +```c# +using System.Text.Json.Nodes; +using Contentstack.Utils; + +JsonObject entry = JsonNode.Parse(entryJson)!.AsObject(); + +// Get variant aliases for a single entry +JsonObject aliases = Utils.GetVariantAliases(entry, "product"); + +// Build the data-csvariants attribute payload for a single entry +JsonObject variantTags = Utils.GetVariantMetadataTags(entry, "product"); + +// Both methods also accept a JsonArray of entries +JsonArray entries = JsonNode.Parse(entriesJson)!.AsArray(); +JsonArray aliasesForEntries = Utils.GetVariantAliases(entries, "product"); +JsonObject variantTagsForEntries = Utils.GetVariantMetadataTags(entries, "product"); +``` + ## Basic Queries Contentstack Utils SDK lets you interact with the Content Delivery APIs and retrieve embedded items from the RTE field of an entry. diff --git a/Scripts/refresh-region.py b/Scripts/refresh-region.py new file mode 100644 index 0000000..d7ed101 --- /dev/null +++ b/Scripts/refresh-region.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +# Refresh regions.json from the Contentstack CDN. +# Usage (run from your project root): +# python3 Scripts/refresh-region.py +# python Scripts/refresh-region.py + +import glob +import json +import os +import ssl +import sys +import urllib.request + +REGIONS_URL = "https://artifacts.contentstack.com/regions.json" + +print(f"Fetching {REGIONS_URL} ...") + + +def _fetch(url): + # First attempt: normal SSL verification + try: + with urllib.request.urlopen(url, timeout=30) as r: + return r.read().decode("utf-8") + except urllib.error.URLError as e: + if "CERTIFICATE_VERIFY_FAILED" not in str(e): + raise + # macOS python.org builds often lack system certs — retry without verification + print("WARNING: SSL certificate verification failed. Retrying without verification.", file=sys.stderr) + print(" To fix permanently, run: /Applications/Python*/Install\\ Certificates.command", file=sys.stderr) + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + with urllib.request.urlopen(url, timeout=30, context=ctx) as r: + return r.read().decode("utf-8") + + +try: + raw = _fetch(REGIONS_URL) +except Exception as e: + print(f"ERROR: Could not download regions.json: {e}", file=sys.stderr) + sys.exit(1) + +try: + data = json.loads(raw) +except json.JSONDecodeError as e: + print(f"ERROR: Downloaded content is not valid JSON: {e}", file=sys.stderr) + sys.exit(1) + +if "regions" not in data: + print("ERROR: Downloaded JSON does not contain a 'regions' key.", file=sys.stderr) + sys.exit(1) + +region_count = len(data["regions"]) + +# Scan bin/ for every copy of the DLL (covers Debug/Release, any TFM, any nesting). +dll_pattern = os.path.join(os.getcwd(), "**", "Contentstack.Utils.dll") +found = [ + os.path.dirname(dll) + for dll in glob.glob(dll_pattern, recursive=True) + if os.sep + "bin" + os.sep in dll +] + +if not found: + print("[bin] No build output found — run 'dotnet build' first, then re-run this script.") + sys.exit(1) + +for bin_dir in found: + assets_dir = os.path.join(bin_dir, "Assets") + os.makedirs(assets_dir, exist_ok=True) + dest = os.path.join(assets_dir, "regions.json") + with open(dest, "w", encoding="utf-8") as f: + f.write(raw) + print(f"[bin] Wrote {region_count} regions → {dest}") diff --git a/build/contentstack.utils.targets b/build/contentstack.utils.targets new file mode 100644 index 0000000..5dca070 --- /dev/null +++ b/build/contentstack.utils.targets @@ -0,0 +1,14 @@ + + + <_RefreshScriptDest>$(MSBuildProjectDirectory)/Scripts/refresh-region.py + <_RefreshScriptSrc>$(MSBuildThisFileDirectory)../contentFiles/cs/any/Scripts/refresh-region.py + + + + + + + +