diff --git a/CHANGELOG.md b/CHANGELOG.md index c07e91a4..af749fe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +### Version: 2.29.0 +#### Date: Jul-16-2026 + +##### Feat: +- Taxonomy / Term / TermQuery — localisation support + - Added `SetLocale(string)`, `IncludeFallback()`, and `AddParam(string, string)` chainable methods to `Taxonomy`, `Term`, and `TermQuery`, matching the convention used by `Asset`, `Entry`, and `AssetLibrary` + - `Taxonomy.Fetch()` and `Term.Fetch()` now read from the shared `UrlQueries` dict — locale and fallback are set via chaining, not method parameters + - Fixed `include_fallback` serialization: value is sent as the string `"true"` instead of a C# `bool` (which serialized as `"True"` and was rejected by the CDA) + - Added `AddParam(string, string)` to `TermQuery` (was missing) + +##### Fix: +- Taxonomy / Term / TermQuery — branch handling + - Removed `?? "main"` fallback on `Config.Branch` across `Taxonomy.Fetch()`, `Term.ExecuteRequest()`, and `TermQuery.Find()`. The fallback was injecting `branch=main` into every CDA request when branching was not configured, causing `422 Branch not found` errors. Branch is now passed directly from `Config.Branch`, consistent with `Asset` and `Entry`. +- Taxonomy / Term / TermQuery — structured error propagation + - Replaced `TaxonomyException.CreateForProcessingError(ex)` with `GetContentstackError(ex)` in all catch blocks across `Taxonomy.Fetch()`, `Term.Fetch()`, `Term.Locales()`, `Term.Ancestors()`, `Term.Descendants()`, and `TermQuery.Find()`. On 4xx responses, `ErrorCode`, `StatusCode`, and `Errors` are now correctly populated from the API response body, consistent with `Asset` and `Entry` error handling. + +##### Migration: +- `Taxonomies("uid").Fetch("hi-in")` → `Taxonomies("uid").SetLocale("hi-in").Fetch()` +- `Term("uid").Fetch("hi-in", includeFallback: true)` → `Term("uid").SetLocale("hi-in").IncludeFallback().Fetch()` + ### Version: 2.28.0 #### Date: Jun-24-2026 diff --git a/Contentstack.Core.Tests/Helpers/TestDataHelper.cs b/Contentstack.Core.Tests/Helpers/TestDataHelper.cs index 9499df51..687f5af5 100644 --- a/Contentstack.Core.Tests/Helpers/TestDataHelper.cs +++ b/Contentstack.Core.Tests/Helpers/TestDataHelper.cs @@ -117,19 +117,31 @@ static TestDataHelper() #endregion #region Taxonomy - + /// /// Gets the taxonomy term for USA state (e.g., "california") /// - public static string TaxUsaState => + public static string TaxUsaState => GetRequiredConfig("TAX_USA_STATE"); - + /// /// Gets the taxonomy term for India state (e.g., "maharashtra") /// - public static string TaxIndiaState => + public static string TaxIndiaState => GetRequiredConfig("TAX_INDIA_STATE"); - + + /// + /// UID of the published taxonomy to use in localization tests (e.g. "gadgets"). + /// + public static string TaxPublishTaxonomyUid => + GetRequiredConfig("TAX_PUBLISH_TAXONOMY_UID"); + + /// + /// Locale code used for localized taxonomy/term delivery tests (e.g. "hi-in"). + /// + public static string TaxPublishLocale => + GetRequiredConfig("TAX_PUBLISH_LOCALE"); + #endregion #region Live Preview diff --git a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs new file mode 100644 index 00000000..223f936f --- /dev/null +++ b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; +using Contentstack.Core.Configuration; +using Contentstack.Core.Models; +using Contentstack.Core.Internals; +using Contentstack.Core.Tests.Helpers; + +namespace Contentstack.Core.Tests.Integration.Taxonomy +{ + /// + /// Integration tests for localized taxonomy and term delivery (CDA). + /// Covers: Taxonomy.Fetch(locale), TermQuery.SetLocale/IncludeFallback/Find, + /// Term.Fetch(locale), Term.Locales, Term.Ancestors, Term.Descendants. + /// Stack: gadgets taxonomy, locales en-us / hi-in. + /// + [Trait("Category", "TaxonomyLocalisation")] + public class TaxonomyLocalisationTest : IntegrationTestBase + { + public TaxonomyLocalisationTest(ITestOutputHelper output) : base(output) { } + + /// + /// Creates a client scoped to the gadgets taxonomy stack. + /// Host must be set from config — the test stack lives on a non-prod CDN. + /// + private ContentstackClient CreateGadgetsClient() + { + var options = new ContentstackOptions + { + ApiKey = TestDataHelper.ApiKey, + DeliveryToken = TestDataHelper.DeliveryToken, + Environment = TestDataHelper.Environment, + Host = TestDataHelper.Host + }; + var client = new ContentstackClient(options); + client.Plugins.Add(new RequestLoggingPlugin(TestOutput)); + return client; + } + + // ── 1. Fetch localized taxonomy ────────────────────────────────────── + + [Fact(DisplayName = "TaxPublish - Fetch taxonomy returns object with uid and name")] + public async Task Fetch_Taxonomy_MasterLocale_ReturnsValidObject() + { + LogArrange("Fetching taxonomy without locale (master)"); + LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid); + + var client = CreateGadgetsClient(); + + LogAct("Calling Taxonomies(uid).Fetch()"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Fetch(); + + LogAssert("Verifying response"); + Assert.NotNull(result); + Assert.NotNull(result["uid"]?.ToString()); + Assert.Equal(TestDataHelper.TaxPublishTaxonomyUid, result["uid"]?.ToString()); + } + + [Fact(DisplayName = "TaxPublish - Fetch taxonomy with locale returns localized name")] + public async Task Fetch_Taxonomy_WithLocale_ReturnsLocalizedName() + { + LogArrange("Fetching taxonomy with locale"); + LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid); + LogContext("Locale", TestDataHelper.TaxPublishLocale); + + var client = CreateGadgetsClient(); + + LogAct("Calling Taxonomies(uid).SetLocale(locale).Fetch()"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .SetLocale(TestDataHelper.TaxPublishLocale) + .Fetch(); + + LogAssert("Verifying response"); + Assert.NotNull(result); + Assert.NotNull(result["uid"]?.ToString()); + // The name should differ from the master locale (en-us) value + Assert.NotNull(result["name"]?.ToString()); + } + + // ── 2. Find all taxonomies ──────────────────────────────────────────── + + [Fact(DisplayName = "TaxPublish - Find all terms without locale returns master-locale terms")] + public async Task Find_AllTaxonomies_ReturnsCollection() + { + LogArrange("Fetching all terms in master locale (no locale filter)"); + LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid); + + var client = CreateGadgetsClient(); + + LogAct("Calling Taxonomies(uid).Terms().Find()"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Terms() + .Find(); + + LogAssert("Verifying response"); + Assert.NotNull(result); + Assert.NotNull(result.Items); + Assert.True(result.Items.Any()); + } + + // ── 3. Find terms with locale ───────────────────────────────────────── + + [Fact(DisplayName = "TaxPublish - Find terms with locale returns localized terms")] + public async Task Find_Terms_WithLocale_ReturnsLocalizedTerms() + { + LogArrange("Finding terms with locale"); + LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid); + LogContext("Locale", TestDataHelper.TaxPublishLocale); + + var client = CreateGadgetsClient(); + + LogAct("Calling Terms().SetLocale(locale).Find()"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Terms() + .SetLocale(TestDataHelper.TaxPublishLocale) + .Find(); + + LogAssert("Verifying response"); + Assert.NotNull(result); + Assert.NotNull(result.Items); + } + + [Fact(DisplayName = "TaxPublish - Find terms with locale and fallback returns terms")] + public async Task Find_Terms_WithLocaleAndFallback_ReturnsTerms() + { + LogArrange("Finding terms with locale and include_fallback"); + LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid); + LogContext("Locale", TestDataHelper.TaxPublishLocale); + + var client = CreateGadgetsClient(); + + LogAct("Calling Terms().SetLocale(locale).IncludeFallback().Find()"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Terms() + .SetLocale(TestDataHelper.TaxPublishLocale) + .IncludeFallback() + .Find(); + + LogAssert("Verifying response"); + Assert.NotNull(result); + Assert.NotNull(result.Items); + Assert.True(result.Items.Any()); + } + + // ── 4–7. Single term methods ────────────────────────────────────────── + + /// + /// Fetches the first available term UID from the gadgets taxonomy to use in subsequent tests. + /// + private async Task GetFirstTermUidAsync(ContentstackClient client) + { + var terms = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Terms() + .SetLocale(TestDataHelper.TaxPublishLocale) + .IncludeFallback() + .Find(); + + return terms?.Items?.FirstOrDefault()?["uid"]?.ToString(); + } + + [Fact(DisplayName = "TaxPublish - Fetch single term with locale returns localized term")] + public async Task Fetch_SingleTerm_WithLocale_ReturnsLocalizedTerm() + { + var client = CreateGadgetsClient(); + var termUid = await GetFirstTermUidAsync(client); + + if (string.IsNullOrEmpty(termUid)) + { + Output.WriteLine("No term UID found — skipping test."); + return; + } + + LogArrange("Fetching single term with locale"); + LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid); + LogContext("TermUid", termUid); + LogContext("Locale", TestDataHelper.TaxPublishLocale); + + LogAct("Calling Term(termUid).SetLocale(locale).IncludeFallback().Fetch()"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Term(termUid) + .SetLocale(TestDataHelper.TaxPublishLocale) + .IncludeFallback() + .Fetch(); + + LogAssert("Verifying response"); + Assert.NotNull(result); + Assert.NotNull(result["uid"]?.ToString()); + Assert.Equal(termUid, result["uid"]?.ToString()); + } + + [Fact(DisplayName = "TaxPublish - Term.Locales returns locales collection")] + public async Task Term_Locales_ReturnsLocalesCollection() + { + var client = CreateGadgetsClient(); + var termUid = await GetFirstTermUidAsync(client); + + if (string.IsNullOrEmpty(termUid)) + { + Output.WriteLine("No term UID found — skipping test."); + return; + } + + LogArrange("Fetching all locales for a term"); + LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid); + LogContext("TermUid", termUid); + + LogAct("Calling Term(termUid).Locales()"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Term(termUid) + .Locales(); + + LogAssert("Verifying response"); + Assert.NotNull(result); + } + + [Fact(DisplayName = "TaxPublish - Term.Ancestors returns ancestors collection")] + public async Task Term_Ancestors_ReturnsAncestorsCollection() + { + var client = CreateGadgetsClient(); + var termUid = await GetFirstTermUidAsync(client); + + if (string.IsNullOrEmpty(termUid)) + { + Output.WriteLine("No term UID found — skipping test."); + return; + } + + LogArrange("Fetching ancestors for a term"); + LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid); + LogContext("TermUid", termUid); + + LogAct("Calling Term(termUid).Ancestors()"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Term(termUid) + .Ancestors(); + + LogAssert("Verifying response"); + Assert.NotNull(result); + } + + [Fact(DisplayName = "TaxPublish - Term.Descendants returns descendants collection")] + public async Task Term_Descendants_ReturnsDescendantsCollection() + { + var client = CreateGadgetsClient(); + var termUid = await GetFirstTermUidAsync(client); + + if (string.IsNullOrEmpty(termUid)) + { + Output.WriteLine("No term UID found — skipping test."); + return; + } + + LogArrange("Fetching descendants for a term"); + LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid); + LogContext("TermUid", termUid); + + LogAct("Calling Term(termUid).Descendants()"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Term(termUid) + .Descendants(); + + LogAssert("Verifying response"); + Assert.NotNull(result); + } + } +} diff --git a/Contentstack.Core.Unit.Tests/QueryUnitTests.cs b/Contentstack.Core.Unit.Tests/QueryUnitTests.cs index 4e1c4ff3..f8a98137 100644 --- a/Contentstack.Core.Unit.Tests/QueryUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/QueryUnitTests.cs @@ -2437,8 +2437,8 @@ public void Query_WithTaxonomyInstance_Setup_VerifiesConfig() // Act - Just verify setup - Taxonomy extends Query, so it has QueryInstance properties var taxonomyType = typeof(Taxonomy); - var stackProperty = taxonomyType.GetProperty("Stack", - BindingFlags.Public | BindingFlags.Instance); + var stackProperty = taxonomyType.GetProperty("Stack", + BindingFlags.NonPublic | BindingFlags.Instance); // Assert Assert.NotNull(taxonomy); @@ -2460,8 +2460,8 @@ public void Query_WithTaxonomyInstanceAndEnvironment_Setup_VerifiesConfig() // Act - Just verify setup var taxonomyType = typeof(Taxonomy); - var stackProperty = taxonomyType.GetProperty("Stack", - BindingFlags.Public | BindingFlags.Instance); + var stackProperty = taxonomyType.GetProperty("Stack", + BindingFlags.NonPublic | BindingFlags.Instance); // Assert Assert.NotNull(taxonomy); @@ -2484,8 +2484,8 @@ public void Query_WithTaxonomyInstanceAndBranch_Setup_VerifiesConfig() // Act - Just verify setup var taxonomyType = typeof(Taxonomy); - var stackProperty = taxonomyType.GetProperty("Stack", - BindingFlags.Public | BindingFlags.Instance); + var stackProperty = taxonomyType.GetProperty("Stack", + BindingFlags.NonPublic | BindingFlags.Instance); // Assert Assert.NotNull(taxonomy); diff --git a/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs b/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs index f4f5e92f..a7c49388 100644 --- a/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs @@ -563,6 +563,369 @@ public void Below_WithZeroValue_AddsQueryParameter() } #endregion + + #region Taxonomy UID Constructor Tests + + [Fact] + public void Taxonomy_WithUid_DoesNotThrow() + { + var taxonomy = _client.Taxonomies("gadgets"); + Assert.NotNull(taxonomy); + } + + [Fact] + public void Taxonomy_WithNullUid_ThrowsTaxonomyException() + { + Assert.Throws(() => _client.Taxonomies(null)); + } + + [Fact] + public void Taxonomy_WithEmptyUid_ThrowsTaxonomyException() + { + Assert.Throws(() => _client.Taxonomies(string.Empty)); + } + + #endregion + + #region Taxonomy.Term() Tests + + [Fact] + public void Taxonomy_TermWithUid_ReturnsTaxonomyInstance() + { + var taxonomy = _client.Taxonomies("gadgets"); + var term = taxonomy.Term("smartwatch"); + Assert.NotNull(term); + Assert.IsType(term); + } + + [Fact] + public void Taxonomy_TermWithoutUid_ThrowsTaxonomyException() + { + var taxonomy = _client.Taxonomies(); + Assert.Throws(() => taxonomy.Term("smartwatch")); + } + + #endregion + + #region Taxonomy.Terms() Tests + + [Fact] + public void Taxonomy_Terms_ReturnsTermQueryInstance() + { + var taxonomy = _client.Taxonomies("gadgets"); + var termQuery = taxonomy.Terms(); + Assert.NotNull(termQuery); + Assert.IsType(termQuery); + } + + [Fact] + public void Taxonomy_Terms_WithoutUid_ThrowsTaxonomyException() + { + var taxonomy = _client.Taxonomies(); + Assert.Throws(() => taxonomy.Terms()); + } + + #endregion + + #region TermQuery Tests + + [Fact] + public void TermQuery_SetLocale_ReturnsSelfForChaining() + { + var termQuery = _client.Taxonomies("gadgets").Terms(); + var result = termQuery.SetLocale("hi-in"); + Assert.Same(termQuery, result); + } + + [Fact] + public void TermQuery_SetLocale_SetsLocaleParam() + { + var termQuery = _client.Taxonomies("gadgets").Terms(); + termQuery.SetLocale("hi-in"); + + var field = typeof(TermQuery).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(termQuery); + + Assert.True(queryParams?.ContainsKey("locale") ?? false); + Assert.Equal("hi-in", queryParams["locale"]); + } + + [Fact] + public void TermQuery_SetLocale_WithNull_DoesNotSetParam() + { + var termQuery = _client.Taxonomies("gadgets").Terms(); + termQuery.SetLocale(null); + + var field = typeof(TermQuery).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var urlQueries = (Dictionary)field?.GetValue(termQuery); + + Assert.False(urlQueries?.ContainsKey("locale") ?? false); + } + + [Fact] + public void TermQuery_IncludeFallback_ReturnsSelfForChaining() + { + var termQuery = _client.Taxonomies("gadgets").Terms(); + var result = termQuery.IncludeFallback(); + Assert.Same(termQuery, result); + } + + [Fact] + public void TermQuery_IncludeFallback_SetsParam() + { + var termQuery = _client.Taxonomies("gadgets").Terms(); + termQuery.IncludeFallback(); + + var field = typeof(TermQuery).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(termQuery); + + Assert.True(queryParams?.ContainsKey("include_fallback") ?? false); + Assert.Equal("true", queryParams["include_fallback"]); + } + + [Fact] + public void TermQuery_SetLocale_Then_IncludeFallback_ChainsBoth() + { + var termQuery = _client.Taxonomies("gadgets").Terms() + .SetLocale("hi-in") + .IncludeFallback(); + + var field = typeof(TermQuery).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(termQuery); + + Assert.True(queryParams?.ContainsKey("locale") ?? false); + Assert.True(queryParams?.ContainsKey("include_fallback") ?? false); + } + + [Fact] + public void TermQuery_AddParam_SetsUrlQuery() + { + var termQuery = _client.Taxonomies("gadgets").Terms(); + termQuery.AddParam("custom_key", "custom_value"); + + var field = typeof(TermQuery).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(termQuery); + + Assert.True(queryParams?.ContainsKey("custom_key") ?? false); + Assert.Equal("custom_value", queryParams["custom_key"]); + } + + #endregion + + #region Taxonomy.SetLocale / IncludeFallback / AddParam Tests + + [Fact] + public void Taxonomy_SetLocale_ReturnsSelfForChaining() + { + var taxonomy = _client.Taxonomies("gadgets"); + var result = taxonomy.SetLocale("hi-in"); + Assert.Same(taxonomy, result); + } + + [Fact] + public void Taxonomy_SetLocale_SetsUrlQuery() + { + var taxonomy = _client.Taxonomies("gadgets"); + taxonomy.SetLocale("hi-in"); + + var field = typeof(Taxonomy).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var urlQueries = (Dictionary)field?.GetValue(taxonomy); + + Assert.True(urlQueries?.ContainsKey("locale") ?? false); + Assert.Equal("hi-in", urlQueries["locale"]); + } + + [Fact] + public void Taxonomy_SetLocale_WithNull_DoesNotSetParam() + { + var taxonomy = _client.Taxonomies("gadgets"); + taxonomy.SetLocale(null); + + var field = typeof(Taxonomy).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var urlQueries = (Dictionary)field?.GetValue(taxonomy); + + Assert.False(urlQueries?.ContainsKey("locale") ?? false); + } + + [Fact] + public void Taxonomy_IncludeFallback_ReturnsSelfForChaining() + { + var taxonomy = _client.Taxonomies("gadgets"); + var result = taxonomy.IncludeFallback(); + Assert.Same(taxonomy, result); + } + + [Fact] + public void Taxonomy_IncludeFallback_SetsUrlQuery() + { + var taxonomy = _client.Taxonomies("gadgets"); + taxonomy.IncludeFallback(); + + var field = typeof(Taxonomy).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var urlQueries = (Dictionary)field?.GetValue(taxonomy); + + Assert.True(urlQueries?.ContainsKey("include_fallback") ?? false); + Assert.Equal("true", urlQueries["include_fallback"]); + } + + [Fact] + public void Taxonomy_AddParam_SetsUrlQuery() + { + var taxonomy = _client.Taxonomies("gadgets"); + taxonomy.AddParam("custom_key", "custom_value"); + + var field = typeof(Taxonomy).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var urlQueries = (Dictionary)field?.GetValue(taxonomy); + + Assert.True(urlQueries?.ContainsKey("custom_key") ?? false); + Assert.Equal("custom_value", urlQueries["custom_key"]); + } + + [Fact] + public void Taxonomy_SetLocale_Then_IncludeFallback_ChainsBoth() + { + var taxonomy = _client.Taxonomies("gadgets") + .SetLocale("hi-in") + .IncludeFallback(); + + var field = typeof(Taxonomy).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var urlQueries = (Dictionary)field?.GetValue(taxonomy); + + Assert.True(urlQueries?.ContainsKey("locale") ?? false); + Assert.True(urlQueries?.ContainsKey("include_fallback") ?? false); + } + + #endregion + + #region Term.SetLocale / IncludeFallback / AddParam Tests + + [Fact] + public void Term_SetLocale_ReturnsSelfForChaining() + { + var term = _client.Taxonomies("gadgets").Term("smartwatch"); + var result = term.SetLocale("hi-in"); + Assert.Same(term, result); + } + + [Fact] + public void Term_SetLocale_SetsQueryParam() + { + var term = _client.Taxonomies("gadgets").Term("smartwatch"); + term.SetLocale("hi-in"); + + var field = typeof(Term).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(term); + + Assert.True(queryParams?.ContainsKey("locale") ?? false); + Assert.Equal("hi-in", queryParams["locale"]); + } + + [Fact] + public void Term_SetLocale_WithNull_DoesNotSetParam() + { + var term = _client.Taxonomies("gadgets").Term("smartwatch"); + term.SetLocale(null); + + var field = typeof(Term).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(term); + + Assert.False(queryParams?.ContainsKey("locale") ?? false); + } + + [Fact] + public void Term_IncludeFallback_ReturnsSelfForChaining() + { + var term = _client.Taxonomies("gadgets").Term("smartwatch"); + var result = term.IncludeFallback(); + Assert.Same(term, result); + } + + [Fact] + public void Term_IncludeFallback_SetsQueryParam() + { + var term = _client.Taxonomies("gadgets").Term("smartwatch"); + term.IncludeFallback(); + + var field = typeof(Term).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(term); + + Assert.True(queryParams?.ContainsKey("include_fallback") ?? false); + Assert.Equal("true", queryParams["include_fallback"]); + } + + [Fact] + public void Term_AddParam_SetsQueryParam() + { + var term = _client.Taxonomies("gadgets").Term("smartwatch"); + term.AddParam("custom_key", "custom_value"); + + var field = typeof(Term).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(term); + + Assert.True(queryParams?.ContainsKey("custom_key") ?? false); + Assert.Equal("custom_value", queryParams["custom_key"]); + } + + [Fact] + public void Term_SetLocale_Then_IncludeFallback_ChainsBoth() + { + var term = _client.Taxonomies("gadgets").Term("smartwatch") + .SetLocale("hi-in") + .IncludeFallback(); + + var field = typeof(Term).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(term); + + Assert.True(queryParams?.ContainsKey("locale") ?? false); + Assert.True(queryParams?.ContainsKey("include_fallback") ?? false); + } + + #endregion + + #region Term Constructor Validation Tests + + [Fact] + public void Term_WithNullTaxonomyUid_ThrowsTaxonomyException() + { + Assert.Throws(() => + { + var t = typeof(Term) + .GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null, + new[] { typeof(ContentstackClient), typeof(string), typeof(string) }, null); + try { t?.Invoke(new object[] { _client, null, "smartwatch" }); } + catch (System.Reflection.TargetInvocationException ex) { throw ex.InnerException; } + }); + } + + [Fact] + public void Term_WithNullTermUid_ThrowsTaxonomyException() + { + Assert.Throws(() => + { + var t = typeof(Term) + .GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null, + new[] { typeof(ContentstackClient), typeof(string), typeof(string) }, null); + try { t?.Invoke(new object[] { _client, "gadgets", null }); } + catch (System.Reflection.TargetInvocationException ex) { throw ex.InnerException; } + }); + } + + #endregion } } diff --git a/Contentstack.Core/ContentstackClient.cs b/Contentstack.Core/ContentstackClient.cs index 0c04d52d..71242079 100644 --- a/Contentstack.Core/ContentstackClient.cs +++ b/Contentstack.Core/ContentstackClient.cs @@ -608,6 +608,25 @@ public Taxonomy Taxonomies() return tx; } + /// + /// Returns a instance scoped to the given taxonomy UID, + /// providing access to published taxonomy data, terms, and locale-aware delivery via the CDA. + /// + /// The UID of the published taxonomy. + /// A instance scoped to the given UID. + /// + /// + /// ContentstackClient stack = new ContentstackClient("api_key", "delivery_token", "environment"); + /// var taxonomy = await stack.Taxonomies("gadgets").Fetch<MyTaxonomy>(); + /// var terms = await stack.Taxonomies("gadgets").Terms().SetLocale("hi-in").Find<MyTerm>(); + /// var term = await stack.Taxonomies("gadgets").Term("smartwatch").Fetch<MyTerm>("mr-in"); + /// + /// + public Taxonomy Taxonomies(string uid) + { + return new Taxonomy(this, uid); + } + /// /// Get version. /// diff --git a/Contentstack.Core/Models/Taxonomy.cs b/Contentstack.Core/Models/Taxonomy.cs index 9d7322f0..6d04455e 100644 --- a/Contentstack.Core/Models/Taxonomy.cs +++ b/Contentstack.Core/Models/Taxonomy.cs @@ -2,12 +2,19 @@ using System.Collections.Generic; using System.IO; using System.Net; +using System.Threading.Tasks; using Contentstack.Core.Configuration; using Contentstack.Core.Internals; using Newtonsoft.Json.Linq; namespace Contentstack.Core.Models { + /// + /// Represents a published taxonomy from the CDA, providing methods to fetch the taxonomy, + /// list its terms, and navigate to individual terms. + /// Use , , and to + /// configure the request before calling . + /// public class Taxonomy: Query { @@ -16,6 +23,7 @@ public class Taxonomy: Query private Dictionary _Headers = new Dictionary(); private Dictionary _StackHeaders = new Dictionary(); private Dictionary UrlQueries = new Dictionary(); + private string _uid = null; protected override string _Url { @@ -30,17 +38,18 @@ protected override string _Url throw new TaxonomyException("Taxonomy Stack Config is null. Please ensure the ContentstackClient is properly configured."); } Config config = this.Stack.Config; + if (_uid != null) + return String.Format("{0}/taxonomies/{1}", config.BaseUrl, _uid); return String.Format("{0}/taxonomies/entries", config.BaseUrl); } } #endregion - public ContentstackClient Stack + internal new ContentstackClient Stack { get; set; } - #region Internal Constructors internal Taxonomy() @@ -56,9 +65,159 @@ internal Taxonomy(ContentstackClient stack): base(stack) this._StackHeaders = stack._LocalHeaders; } + internal Taxonomy(ContentstackClient stack, string uid) : this(stack) + { + if (string.IsNullOrEmpty(uid)) + throw new TaxonomyException("Taxonomy UID cannot be null or empty."); + _uid = uid; + } + #endregion #region Public Functions + /// + /// Sets the locale for this taxonomy fetch. + /// Passing null or empty is a no-op. + /// + /// Locale code (e.g. "hi-in", "en-us"). + /// The current for chaining. + /// + /// + /// var taxonomy = await stack.Taxonomies("gadgets").SetLocale("hi-in").Fetch<MyTaxonomy>(); + /// + /// + public new Taxonomy SetLocale(string locale) + { + if (!string.IsNullOrEmpty(locale)) + UrlQueries["locale"] = locale; + return this; + } + + /// + /// Falls back to the master locale when the taxonomy is not published in the requested locale. + /// Can be used with or without . + /// + /// The current for chaining. + /// + /// + /// var taxonomy = await stack.Taxonomies("gadgets").SetLocale("hi-in").IncludeFallback().Fetch<MyTaxonomy>(); + /// + /// + public new Taxonomy IncludeFallback() + { + UrlQueries["include_fallback"] = "true"; + return this; + } + + /// + /// Adds a custom query parameter to the request. + /// + /// Parameter key. + /// Parameter value. + /// The current for chaining. + /// + /// + /// var taxonomy = await stack.Taxonomies("gadgets").AddParam("include_branch", "true").Fetch<MyTaxonomy>(); + /// + /// + public new Taxonomy AddParam(string key, string value) + { + UrlQueries[key] = value; + return this; + } + + /// + /// Returns a Term instance for the given term UID within this taxonomy. + /// Requires the Taxonomy to be initialised with a UID via client.Taxonomies("uid"). + /// + /// The UID of the term to retrieve. + /// A instance scoped to this taxonomy and term. + /// + /// + /// var term = await stack.Taxonomies("gadgets").Term("smartwatch").Fetch<MyTerm>(); + /// + /// + public Term Term(string termUid) + { + if (_uid == null) + throw new TaxonomyException("Term() requires a taxonomy UID. Use client.Taxonomies(\"uid\") to scope to a specific taxonomy."); + return new Term(Stack, _uid, termUid); + } + + /// + /// Returns a TermQuery for listing all published terms within this taxonomy. + /// Requires the Taxonomy to be initialised with a UID via client.Taxonomies("uid"). + /// + /// A instance for this taxonomy. + /// + /// + /// var terms = await stack.Taxonomies("gadgets").Terms().SetLocale("hi-in").Find<MyTerm>(); + /// + /// + public TermQuery Terms() + { + if (_uid == null) + throw new TaxonomyException("Terms() requires a taxonomy UID. Use client.Taxonomies(\"uid\") to scope to a specific taxonomy."); + return new TermQuery(Stack, _uid); + } + + /// + /// Fetches the published taxonomy by its UID from the CDA. + /// Requires the Taxonomy to be initialised with a UID via client.Taxonomies("uid"). + /// Use and to set query parameters before calling. + /// + /// The deserialized taxonomy object. + /// + /// + /// ContentstackClient stack = new ContentstackClient("api_key", "delivery_token", "environment"); + /// var taxonomy = await stack.Taxonomies("gadgets").Fetch<MyTaxonomy>(); + /// var localized = await stack.Taxonomies("gadgets").SetLocale("hi-in").Fetch<MyTaxonomy>(); + /// var withFallback = await stack.Taxonomies("gadgets").SetLocale("hi-in").IncludeFallback().Fetch<MyTaxonomy>(); + /// + /// + public async Task Fetch() + { + if (_uid == null) + throw new TaxonomyException("Fetch() requires a taxonomy UID. Use client.Taxonomies(\"uid\") to scope to a specific taxonomy."); + + try + { + var headerAll = new Dictionary(); + foreach (var header in Stack._LocalHeaders) + headerAll[header.Key] = header.Value; + + var mainJson = new Dictionary(); + if (Stack.Config?.Environment != null) + mainJson["environment"] = Stack.Config.Environment; + foreach (var kvp in UrlQueries) + mainJson[kvp.Key] = kvp.Value; + + var handler = new HttpRequestHandler(Stack); + var result = await handler.ProcessRequest( + _Url, headerAll, mainJson, + Branch: Stack.Config.Branch, + timeout: Stack.Config.Timeout, + proxy: Stack.Config.Proxy + ); + + var jObject = Newtonsoft.Json.Linq.JObject.Parse(result); + var token = jObject.SelectToken("$.taxonomy"); + if (token != null) + return token.ToObject(Stack.Serializer); + return jObject.ToObject(Stack.Serializer); + } + catch (Exception ex) + { + var contentstackError = GetContentstackError(ex); + throw new TaxonomyException(contentstackError.Message, ex) + { + ErrorCode = contentstackError.ErrorCode, + StatusCode = contentstackError.StatusCode, + Errors = contentstackError.Errors + }; + } + } + /// /// Add a constraint to the query that requires a particular key entry to be less than the provided value. /// @@ -254,7 +413,7 @@ private Dictionary GetHeader(Dictionary localHea return _StackHeaders; } } - internal static ContentstackException GetContentstackError(Exception ex) + internal new static ContentstackException GetContentstackError(Exception ex) { Int32 errorCode = 0; string errorMessage = string.Empty; diff --git a/Contentstack.Core/Models/Term.cs b/Contentstack.Core/Models/Term.cs new file mode 100644 index 00000000..7aeceb05 --- /dev/null +++ b/Contentstack.Core/Models/Term.cs @@ -0,0 +1,258 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Contentstack.Core.Internals; +using Newtonsoft.Json.Linq; + +namespace Contentstack.Core.Models +{ + /// + /// Represents a single published term within a taxonomy, providing methods to fetch + /// the term, its localized versions, ancestors, and descendants from the CDA. + /// Use , , and to + /// configure the request before calling . + /// + public class Term + { + private readonly ContentstackClient _stack; + private readonly string _taxonomyUid; + private readonly string _termUid; + private readonly Dictionary UrlQueries = new Dictionary(); + + private string BaseUrlPath => + $"{_stack.Config.BaseUrl}/taxonomies/{_taxonomyUid}/terms/{_termUid}"; + + internal Term(ContentstackClient stack, string taxonomyUid, string termUid) + { + _stack = stack ?? throw new TaxonomyException("ContentstackClient cannot be null when creating a Term instance."); + if (string.IsNullOrEmpty(taxonomyUid)) throw new TaxonomyException("taxonomyUid cannot be null or empty."); + if (string.IsNullOrEmpty(termUid)) throw new TaxonomyException("termUid cannot be null or empty."); + _taxonomyUid = taxonomyUid; + _termUid = termUid; + } + + /// + /// Sets the locale for this term fetch. + /// Passing null or empty is a no-op. + /// + /// Locale code (e.g. "hi-in", "en-us"). + /// The current for chaining. + /// + /// + /// var term = await stack.Taxonomies("gadgets").Term("smartwatch").SetLocale("hi-in").Fetch<MyTerm>(); + /// + /// + public Term SetLocale(string locale) + { + if (!string.IsNullOrEmpty(locale)) + UrlQueries["locale"] = locale; + return this; + } + + /// + /// Falls back to the master locale when the term is not published in the requested locale. + /// Can be used with or without . + /// + /// The current for chaining. + /// + /// + /// var term = await stack.Taxonomies("gadgets").Term("smartwatch").SetLocale("hi-in").IncludeFallback().Fetch<MyTerm>(); + /// + /// + public Term IncludeFallback() + { + UrlQueries["include_fallback"] = "true"; + return this; + } + + /// + /// Adds a custom query parameter to the request. + /// + /// Parameter key. + /// Parameter value. + /// The current for chaining. + /// + /// + /// var term = await stack.Taxonomies("gadgets").Term("smartwatch").AddParam("include_branch", "true").Fetch<MyTerm>(); + /// + /// + public Term AddParam(string key, string value) + { + UrlQueries[key] = value; + return this; + } + + /// + /// Fetches the published term from the CDA. + /// Maps to GET /v3/taxonomies/{uid}/terms/{termUid} with any configured query parameters. + /// + /// The deserialized term object. + /// + /// + /// var term = await stack.Taxonomies("gadgets").Term("smartwatch").Fetch<MyTerm>(); + /// var localized = await stack.Taxonomies("gadgets").Term("smartwatch").SetLocale("hi-in").Fetch<MyTerm>(); + /// var withFallback = await stack.Taxonomies("gadgets").Term("smartwatch").SetLocale("hi-in").IncludeFallback().Fetch<MyTerm>(); + /// + /// + public async Task Fetch() + { + try + { + var result = await ExecuteRequest(BaseUrlPath, UrlQueries); + var jObject = JObject.Parse(result); + var token = jObject.SelectToken("$.term"); + if (token != null) + return token.ToObject(_stack.Serializer); + return jObject.ToObject(_stack.Serializer); + } + catch (TaxonomyException) + { + throw; + } + catch (Exception ex) + { + var contentstackError = Taxonomy.GetContentstackError(ex); + throw new TaxonomyException(contentstackError.Message, ex) + { + ErrorCode = contentstackError.ErrorCode, + StatusCode = contentstackError.StatusCode, + Errors = contentstackError.Errors + }; + } + } + + /// + /// Fetches all published localized versions of this term across every locale. + /// Maps to GET /v3/taxonomies/{uid}/terms/{termUid}/locales. + /// + /// The deserialized locales collection. + /// + /// + /// var locales = await stack.Taxonomies("gadgets").Term("smartwatch").Locales<JToken>(); + /// + /// + public async Task Locales() + { + try + { + var result = await ExecuteRequest($"{BaseUrlPath}/locales"); + var jObject = JObject.Parse(result); + var token = jObject.SelectToken("$.locales"); + if (token != null) + return token.ToObject(_stack.Serializer); + return jObject.ToObject(_stack.Serializer); + } + catch (TaxonomyException) + { + throw; + } + catch (Exception ex) + { + var contentstackError = Taxonomy.GetContentstackError(ex); + throw new TaxonomyException(contentstackError.Message, ex) + { + ErrorCode = contentstackError.ErrorCode, + StatusCode = contentstackError.StatusCode, + Errors = contentstackError.Errors + }; + } + } + + /// + /// Fetches all ancestors of this term up to the taxonomy root. + /// Maps to GET /v3/taxonomies/{uid}/terms/{termUid}/ancestors. + /// + /// The deserialized ancestors collection. + /// + /// + /// var ancestors = await stack.Taxonomies("gadgets").Term("smartwatch").Ancestors<JToken>(); + /// + /// + public async Task Ancestors() + { + try + { + var result = await ExecuteRequest($"{BaseUrlPath}/ancestors"); + var jObject = JObject.Parse(result); + var token = jObject.SelectToken("$.ancestors"); + if (token != null) + return token.ToObject(_stack.Serializer); + return jObject.ToObject(_stack.Serializer); + } + catch (TaxonomyException) + { + throw; + } + catch (Exception ex) + { + var contentstackError = Taxonomy.GetContentstackError(ex); + throw new TaxonomyException(contentstackError.Message, ex) + { + ErrorCode = contentstackError.ErrorCode, + StatusCode = contentstackError.StatusCode, + Errors = contentstackError.Errors + }; + } + } + + /// + /// Fetches all descendants of this term. + /// Maps to GET /v3/taxonomies/{uid}/terms/{termUid}/descendants. + /// + /// The deserialized descendants collection. + /// + /// + /// var descendants = await stack.Taxonomies("gadgets").Term("smartwatch").Descendants<JToken>(); + /// + /// + public async Task Descendants() + { + try + { + var result = await ExecuteRequest($"{BaseUrlPath}/descendants"); + var jObject = JObject.Parse(result); + var token = jObject.SelectToken("$.descendants"); + if (token != null) + return token.ToObject(_stack.Serializer); + return jObject.ToObject(_stack.Serializer); + } + catch (TaxonomyException) + { + throw; + } + catch (Exception ex) + { + var contentstackError = Taxonomy.GetContentstackError(ex); + throw new TaxonomyException(contentstackError.Message, ex) + { + ErrorCode = contentstackError.ErrorCode, + StatusCode = contentstackError.StatusCode, + Errors = contentstackError.Errors + }; + } + } + + private async Task ExecuteRequest(string url, Dictionary extraParams = null) + { + var headerAll = new Dictionary(); + foreach (var header in _stack._LocalHeaders) + headerAll[header.Key] = header.Value; + + var mainJson = new Dictionary(); + if (_stack.Config?.Environment != null) + mainJson["environment"] = _stack.Config.Environment; + + if (extraParams != null) + foreach (var kvp in extraParams) + mainJson[kvp.Key] = kvp.Value; + + var handler = new HttpRequestHandler(_stack); + return await handler.ProcessRequest( + url, headerAll, mainJson, + Branch: _stack.Config.Branch, + timeout: _stack.Config.Timeout, + proxy: _stack.Config.Proxy + ); + } + } +} diff --git a/Contentstack.Core/Models/TermQuery.cs b/Contentstack.Core/Models/TermQuery.cs new file mode 100644 index 00000000..79502c1e --- /dev/null +++ b/Contentstack.Core/Models/TermQuery.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Contentstack.Core.Internals; +using Newtonsoft.Json.Linq; + +namespace Contentstack.Core.Models +{ + /// + /// Provides a fluent query builder for listing published terms within a taxonomy from the CDA. + /// Use , , and to + /// configure the request before calling . + /// + public class TermQuery + { + private readonly ContentstackClient _stack; + private readonly string _taxonomyUid; + private readonly Dictionary UrlQueries = new Dictionary(); + + private string Url => + $"{_stack.Config.BaseUrl}/taxonomies/{_taxonomyUid}/terms"; + + internal TermQuery(ContentstackClient stack, string taxonomyUid) + { + _stack = stack ?? throw new TaxonomyException("ContentstackClient cannot be null when creating a TermQuery instance."); + if (string.IsNullOrEmpty(taxonomyUid)) throw new TaxonomyException("taxonomyUid cannot be null or empty."); + _taxonomyUid = taxonomyUid; + } + + /// + /// Filters terms to those published in the specified locale. + /// Passing null or empty is a no-op. + /// + /// Locale code (e.g. "hi-in", "en-us"). + /// The current for chaining. + /// + /// + /// var terms = await stack.Taxonomies("gadgets").Terms().SetLocale("hi-in").Find<MyTerm>(); + /// + /// + public TermQuery SetLocale(string locale) + { + if (!string.IsNullOrEmpty(locale)) + UrlQueries["locale"] = locale; + return this; + } + + /// + /// Falls back to the master locale when a term is not published in the requested locale. + /// Can be used with or without . + /// + /// The current for chaining. + /// + /// + /// var terms = await stack.Taxonomies("gadgets").Terms().SetLocale("hi-in").IncludeFallback().Find<MyTerm>(); + /// + /// + public TermQuery IncludeFallback() + { + UrlQueries["include_fallback"] = "true"; + return this; + } + + /// + /// Adds a custom query parameter to the request. + /// + /// Parameter key. + /// Parameter value. + /// The current for chaining. + /// + /// + /// var terms = await stack.Taxonomies("gadgets").Terms().AddParam("depth", "2").Find<MyTerm>(); + /// + /// + public TermQuery AddParam(string key, string value) + { + UrlQueries[key] = value; + return this; + } + + /// + /// Executes the query and returns all matching published terms. + /// Maps to GET /v3/taxonomies/{uid}/terms with any configured query parameters. + /// + /// A containing the matched terms. + /// + /// + /// var result = await stack.Taxonomies("gadgets").Terms().SetLocale("hi-in").IncludeFallback().Find<MyTerm>(); + /// foreach (var term in result.Items) { ... } + /// + /// + public async Task> Find() + { + try + { + var headerAll = new Dictionary(); + foreach (var header in _stack._LocalHeaders) + headerAll[header.Key] = header.Value; + + var mainJson = new Dictionary(); + if (_stack.Config?.Environment != null) + mainJson["environment"] = _stack.Config.Environment; + + foreach (var kvp in UrlQueries) + mainJson[kvp.Key] = kvp.Value; + + var handler = new HttpRequestHandler(_stack); + var result = await handler.ProcessRequest( + Url, headerAll, mainJson, + Branch: _stack.Config.Branch, + timeout: _stack.Config.Timeout, + proxy: _stack.Config.Proxy + ); + + var jObject = JObject.Parse(result); + var terms = jObject.SelectToken("$.terms")?.ToObject>(_stack.Serializer); + var collection = jObject.ToObject>(_stack.Serializer); + collection.Items = terms ?? Enumerable.Empty(); + return collection; + } + catch (TaxonomyException) + { + throw; + } + catch (Exception ex) + { + var contentstackError = Taxonomy.GetContentstackError(ex); + throw new TaxonomyException(contentstackError.Message, ex) + { + ErrorCode = contentstackError.ErrorCode, + StatusCode = contentstackError.StatusCode, + Errors = contentstackError.Errors + }; + } + } + } +} diff --git a/Directory.Build.props b/Directory.Build.props index 79401e83..9e709f08 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 2.28.0 + 2.29.0