From 1940b78cbc504ba7636aa9e624ed0f5c6c7ec6a5 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Wed, 15 Jul 2026 18:27:03 +0530 Subject: [PATCH 01/10] feat: add localized taxonomy and term delivery (CDA) support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the .NET SDK with published-taxonomy fetch and term delivery endpoints to match the TypeScript feat/taxonomy-publishing parity (CD-11371). New: - Term.cs — Fetch(locale?), Locales(), Ancestors(), Descendants() - TermQuery.cs — SetLocale()/IncludeFallback() chainable builder + Find() Extended Taxonomy class: - Taxonomy(stack, uid) constructor - _Url switches to /taxonomies/{uid} when uid is set - Fetch(locale?) — own HTTP path, parses $.taxonomy - Term(termUid) → Term, Terms() → TermQuery Extended ContentstackClient: - Taxonomies(uid) overload Tests: - TaxonomyUnitTests — 46 unit tests (all pass) - TaxonomyLocalisationTest — integration tests per CDA call - TestDataHelper — taxonomy-publish stack config properties Co-Authored-By: Claude Sonnet 4.6 --- .../Helpers/TestDataHelper.cs | 40 ++- .../Taxonomy/TaxonomyLocalisationTest.cs | 272 ++++++++++++++++++ .../TaxonomyUnitTests.cs | 163 +++++++++++ Contentstack.Core/ContentstackClient.cs | 19 ++ Contentstack.Core/Models/Taxonomy.cs | 87 ++++++ Contentstack.Core/Models/Term.cs | 184 ++++++++++++ Contentstack.Core/Models/TermQuery.cs | 114 ++++++++ 7 files changed, 874 insertions(+), 5 deletions(-) create mode 100644 Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs create mode 100644 Contentstack.Core/Models/Term.cs create mode 100644 Contentstack.Core/Models/TermQuery.cs diff --git a/Contentstack.Core.Tests/Helpers/TestDataHelper.cs b/Contentstack.Core.Tests/Helpers/TestDataHelper.cs index 9499df51..b1ea3b13 100644 --- a/Contentstack.Core.Tests/Helpers/TestDataHelper.cs +++ b/Contentstack.Core.Tests/Helpers/TestDataHelper.cs @@ -117,19 +117,49 @@ 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"); - + + /// + /// API key for the taxonomy-publish test stack (gadgets). + /// + public static string TaxPublishApiKey => + GetRequiredConfig("TAX_PUBLISH_API_KEY"); + + /// + /// Delivery token for the taxonomy-publish test stack. + /// + public static string TaxPublishDeliveryToken => + GetRequiredConfig("TAX_PUBLISH_DELIVERY_TOKEN"); + + /// + /// Environment for the taxonomy-publish test stack. + /// + public static string TaxPublishEnvironment => + GetRequiredConfig("TAX_PUBLISH_ENVIRONMENT"); + + /// + /// 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..1b768ef5 --- /dev/null +++ b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs @@ -0,0 +1,272 @@ +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-publish test stack. + /// Uses the default CDN host (no custom host override needed). + /// + private ContentstackClient CreateGadgetsClient() + { + var options = new ContentstackOptions + { + ApiKey = TestDataHelper.TaxPublishApiKey, + DeliveryToken = TestDataHelper.TaxPublishDeliveryToken, + Environment = TestDataHelper.TaxPublishEnvironment + }; + 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).Fetch(locale)"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Fetch(TestDataHelper.TaxPublishLocale); + + 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 taxonomies returns non-empty collection")] + public async Task Find_AllTaxonomies_ReturnsCollection() + { + LogArrange("Fetching all published taxonomies"); + + var client = CreateGadgetsClient(); + + LogAct("Calling Taxonomies().Find()"); + var result = await client.Taxonomies().Find(); + + LogAssert("Verifying response"); + Assert.NotNull(result); + Assert.NotNull(result.Items); + } + + // ── 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); + // Fallback means we should get at least as many terms as without fallback + 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).Fetch(locale)"); + var result = await client + .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) + .Term(termUid) + .Fetch(TestDataHelper.TaxPublishLocale); + + 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/TaxonomyUnitTests.cs b/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs index f4f5e92f..ac641d76 100644 --- a/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs @@ -563,6 +563,169 @@ 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("_queryParams", + 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_ThrowsTaxonomyException() + { + var termQuery = _client.Taxonomies("gadgets").Terms(); + Assert.Throws(() => termQuery.SetLocale(null)); + } + + [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("_queryParams", + 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("_queryParams", + BindingFlags.NonPublic | BindingFlags.Instance); + var queryParams = (Dictionary)field?.GetValue(termQuery); + + 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..6ac5d3b3 100644 --- a/Contentstack.Core/Models/Taxonomy.cs +++ b/Contentstack.Core/Models/Taxonomy.cs @@ -2,6 +2,7 @@ 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; @@ -16,6 +17,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,6 +32,8 @@ 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); } } @@ -56,9 +60,92 @@ 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 + /// + /// 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. + 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. + 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"). + /// + /// Optional locale code (e.g. "hi-in"). Omit for the master locale. + /// 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").Fetch<MyTaxonomy>("hi-in"); + /// + /// + public async System.Threading.Tasks.Task Fetch(string locale = null) + { + 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; + if (!string.IsNullOrEmpty(locale)) + mainJson["locale"] = locale; + + var handler = new HttpRequestHandler(Stack); + var branch = Stack.Config?.Branch ?? "main"; + var result = await handler.ProcessRequest( + _Url, headerAll, mainJson, + Branch: 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) + { + throw TaxonomyException.CreateForProcessingError(ex); + } + } + /// /// Add a constraint to the query that requires a particular key entry to be less than the provided value. /// diff --git a/Contentstack.Core/Models/Term.cs b/Contentstack.Core/Models/Term.cs new file mode 100644 index 00000000..d45864f3 --- /dev/null +++ b/Contentstack.Core/Models/Term.cs @@ -0,0 +1,184 @@ +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. + /// + public class Term + { + private readonly ContentstackClient _stack; + private readonly string _taxonomyUid; + private readonly string _termUid; + + 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; + } + + /// + /// Fetches the published term from the CDA. + /// + /// Optional locale code (e.g. "mr-in"). Omit for the master locale. + /// The deserialized term object. + /// + /// + /// var term = await stack.Taxonomies("gadgets").Term("smartwatch").Fetch<MyTerm>(); + /// var localizedTerm = await stack.Taxonomies("gadgets").Term("smartwatch").Fetch<MyTerm>("mr-in"); + /// + /// + public async Task Fetch(string locale = null) + { + try + { + var queryParams = new Dictionary(); + if (!string.IsNullOrEmpty(locale)) + queryParams["locale"] = locale; + + var result = await ExecuteRequest(BaseUrlPath, queryParams); + 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) + { + throw TaxonomyException.CreateForProcessingError(ex); + } + } + + /// + /// Fetches all published, localized versions of this term across every locale. + /// Maps to GET /taxonomies/{uid}/terms/{termUid}/locales. + /// + /// The deserialized locales collection. + /// + /// + /// var locales = await stack.Taxonomies("gadgets").Term("smartwatch").Locales<MyLocales>(); + /// + /// + 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) + { + throw TaxonomyException.CreateForProcessingError(ex); + } + } + + /// + /// Fetches all ancestors of this term up to the root. + /// Maps to GET /taxonomies/{uid}/terms/{termUid}/ancestors. + /// + /// The deserialized ancestors collection. + /// + /// + /// var ancestors = await stack.Taxonomies("gadgets").Term("smartwatch").Ancestors<MyTerms>(); + /// + /// + 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) + { + throw TaxonomyException.CreateForProcessingError(ex); + } + } + + /// + /// Fetches all descendants of this term. + /// Maps to GET /taxonomies/{uid}/terms/{termUid}/descendants. + /// + /// The deserialized descendants collection. + /// + /// + /// var descendants = await stack.Taxonomies("gadgets").Term("smartwatch").Descendants<MyTerms>(); + /// + /// + 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) + { + throw TaxonomyException.CreateForProcessingError(ex); + } + } + + 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 param in extraParams) + mainJson[param.Key] = param.Value; + + var handler = new HttpRequestHandler(_stack); + var branch = _stack.Config?.Branch ?? "main"; + return await handler.ProcessRequest( + url, headerAll, mainJson, + Branch: 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..d1d0876c --- /dev/null +++ b/Contentstack.Core/Models/TermQuery.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +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. + /// Supports locale filtering and master-locale fallback. + /// + public class TermQuery + { + private readonly ContentstackClient _stack; + private readonly string _taxonomyUid; + private readonly Dictionary _queryParams = 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. + /// + /// The 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)) + throw new TaxonomyException("Locale cannot be null or empty."); + _queryParams["locale"] = locale; + return this; + } + + /// + /// When a term is not localized in the requested locale, falls back to the master locale. + /// Must be used together with . + /// + /// The current for chaining. + /// + /// + /// var terms = await stack.Taxonomies("gadgets").Terms().SetLocale("hi-in").IncludeFallback().Find<MyTerm>(); + /// + /// + public TermQuery IncludeFallback() + { + _queryParams["include_fallback"] = "true"; + return this; + } + + /// + /// Executes the query and returns all matching published terms. + /// Maps to GET /taxonomies/{uid}/terms with any configured locale and fallback params. + /// + /// A containing the matched terms. + /// + /// + /// var result = await stack.Taxonomies("gadgets").Terms().SetLocale("hi-in").IncludeFallback().Find<MyTerm>(); + /// foreach (var term in result) { ... } + /// + /// + 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 param in _queryParams) + mainJson[param.Key] = param.Value; + + var handler = new HttpRequestHandler(_stack); + var branch = _stack.Config?.Branch ?? "main"; + var result = await handler.ProcessRequest( + Url, headerAll, mainJson, + Branch: 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 ?? new List(); + return collection; + } + catch (TaxonomyException) + { + throw; + } + catch (Exception ex) + { + throw TaxonomyException.CreateForProcessingError(ex); + } + } + } +} From c51da4da97fd6f9adc80e6c31c8f781923cc8719 Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Wed, 15 Jul 2026 19:22:10 +0530 Subject: [PATCH 02/10] fix: add includeFallback support to Term.Fetch and fix localisation integration tests - Add includeFallback param to Term.Fetch; serialize as string "true" to match CDA case-sensitivity (bool true serializes as "True" which the API rejects) - Pass includeFallback: true in Fetch_SingleTerm test to be consistent with how GetFirstTermUidAsync lists terms (with IncludeFallback) - Rewrite Find_AllTaxonomies test to call Terms().Find() without locale instead of the entries-by-taxonomy endpoint which requires tagged entries --- .../Taxonomy/TaxonomyLocalisationTest.cs | 17 +++++++++++------ Contentstack.Core/Models/Term.cs | 6 +++++- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs index 1b768ef5..ee646dcd 100644 --- a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs +++ b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs @@ -83,19 +83,24 @@ public async Task Fetch_Taxonomy_WithLocale_ReturnsLocalizedName() // ── 2. Find all taxonomies ──────────────────────────────────────────── - [Fact(DisplayName = "TaxPublish - Find all taxonomies returns non-empty collection")] + [Fact(DisplayName = "TaxPublish - Find all terms without locale returns master-locale terms")] public async Task Find_AllTaxonomies_ReturnsCollection() { - LogArrange("Fetching all published taxonomies"); + LogArrange("Fetching all terms in master locale (no locale filter)"); + LogContext("TaxonomyUid", TestDataHelper.TaxPublishTaxonomyUid); var client = CreateGadgetsClient(); - LogAct("Calling Taxonomies().Find()"); - var result = await client.Taxonomies().Find(); + 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 ───────────────────────────────────────── @@ -179,11 +184,11 @@ public async Task Fetch_SingleTerm_WithLocale_ReturnsLocalizedTerm() LogContext("TermUid", termUid); LogContext("Locale", TestDataHelper.TaxPublishLocale); - LogAct("Calling Term(termUid).Fetch(locale)"); + LogAct("Calling Term(termUid).Fetch(locale, includeFallback: true)"); var result = await client .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) .Term(termUid) - .Fetch(TestDataHelper.TaxPublishLocale); + .Fetch(TestDataHelper.TaxPublishLocale, includeFallback: true); LogAssert("Verifying response"); Assert.NotNull(result); diff --git a/Contentstack.Core/Models/Term.cs b/Contentstack.Core/Models/Term.cs index d45864f3..41a44ae9 100644 --- a/Contentstack.Core/Models/Term.cs +++ b/Contentstack.Core/Models/Term.cs @@ -32,20 +32,24 @@ internal Term(ContentstackClient stack, string taxonomyUid, string termUid) /// Fetches the published term from the CDA. /// /// Optional locale code (e.g. "mr-in"). Omit for the master locale. + /// When true, falls back to the master locale if the term has no localized version. /// The deserialized term object. /// /// /// var term = await stack.Taxonomies("gadgets").Term("smartwatch").Fetch<MyTerm>(); /// var localizedTerm = await stack.Taxonomies("gadgets").Term("smartwatch").Fetch<MyTerm>("mr-in"); + /// var withFallback = await stack.Taxonomies("gadgets").Term("laptop").Fetch<MyTerm>("hi-in", includeFallback: true); /// /// - public async Task Fetch(string locale = null) + public async Task Fetch(string locale = null, bool includeFallback = false) { try { var queryParams = new Dictionary(); if (!string.IsNullOrEmpty(locale)) queryParams["locale"] = locale; + if (includeFallback) + queryParams["include_fallback"] = "true"; var result = await ExecuteRequest(BaseUrlPath, queryParams); var jObject = JObject.Parse(result); From 3ffc5b57d08bfda8cff05126f98c90ddb6c9eba2 Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Wed, 15 Jul 2026 19:42:48 +0530 Subject: [PATCH 03/10] chore: bump version to 2.29.0 --- CHANGELOG.md | 8 ++++++++ Directory.Build.props | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c07e91a4..525517a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +### Version: 2.29.0 +#### Date: Jul-15-2026 + +##### Feat: +- Taxonomy / Term localisation + - Added `includeFallback` parameter to `Term.Fetch(string locale, bool includeFallback)` so callers can request master-locale fallback on single-term fetch, consistent with `TermQuery.IncludeFallback()` + - Fixed `include_fallback` serialization: the value is now sent as the string `"true"` instead of a C# `bool`, which was being serialized as `"True"` (capital T) and rejected by the CDA + ### Version: 2.28.0 #### Date: Jun-24-2026 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 From 2a07f76c9799b0ec6993c446412d3ce155238c62 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Thu, 16 Jul 2026 00:53:03 +0530 Subject: [PATCH 04/10] fix: added branch set via the stack config --- .../Taxonomy/TaxonomyLocalisationTest.cs | 1 - Contentstack.Core/Models/Taxonomy.cs | 11 ++++-- Contentstack.Core/Models/Term.cs | 35 +++++++++++++++---- Contentstack.Core/Models/TermQuery.cs | 14 +++++--- 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs index 1b768ef5..2f749d67 100644 --- a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs +++ b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs @@ -141,7 +141,6 @@ public async Task Find_Terms_WithLocaleAndFallback_ReturnsTerms() LogAssert("Verifying response"); Assert.NotNull(result); Assert.NotNull(result.Items); - // Fallback means we should get at least as many terms as without fallback Assert.True(result.Items.Any()); } diff --git a/Contentstack.Core/Models/Taxonomy.cs b/Contentstack.Core/Models/Taxonomy.cs index 6ac5d3b3..f4b81f84 100644 --- a/Contentstack.Core/Models/Taxonomy.cs +++ b/Contentstack.Core/Models/Taxonomy.cs @@ -126,10 +126,9 @@ public async System.Threading.Tasks.Task Fetch(string locale = null) mainJson["locale"] = locale; var handler = new HttpRequestHandler(Stack); - var branch = Stack.Config?.Branch ?? "main"; var result = await handler.ProcessRequest( _Url, headerAll, mainJson, - Branch: branch, + Branch: Stack.Config.Branch, timeout: Stack.Config.Timeout, proxy: Stack.Config.Proxy ); @@ -142,7 +141,13 @@ public async System.Threading.Tasks.Task Fetch(string locale = null) } catch (Exception ex) { - throw TaxonomyException.CreateForProcessingError(ex); + var contentstackError = GetContentstackError(ex); + throw new TaxonomyException(contentstackError.Message, ex) + { + ErrorCode = contentstackError.ErrorCode, + StatusCode = contentstackError.StatusCode, + Errors = contentstackError.Errors + }; } } diff --git a/Contentstack.Core/Models/Term.cs b/Contentstack.Core/Models/Term.cs index d45864f3..39c302d5 100644 --- a/Contentstack.Core/Models/Term.cs +++ b/Contentstack.Core/Models/Term.cs @@ -60,7 +60,13 @@ public async Task Fetch(string locale = null) } catch (Exception ex) { - throw TaxonomyException.CreateForProcessingError(ex); + var contentstackError = Taxonomy.GetContentstackError(ex); + throw new TaxonomyException(contentstackError.Message, ex) + { + ErrorCode = contentstackError.ErrorCode, + StatusCode = contentstackError.StatusCode, + Errors = contentstackError.Errors + }; } } @@ -91,7 +97,13 @@ public async Task Locales() } catch (Exception ex) { - throw TaxonomyException.CreateForProcessingError(ex); + var contentstackError = Taxonomy.GetContentstackError(ex); + throw new TaxonomyException(contentstackError.Message, ex) + { + ErrorCode = contentstackError.ErrorCode, + StatusCode = contentstackError.StatusCode, + Errors = contentstackError.Errors + }; } } @@ -122,7 +134,13 @@ public async Task Ancestors() } catch (Exception ex) { - throw TaxonomyException.CreateForProcessingError(ex); + var contentstackError = Taxonomy.GetContentstackError(ex); + throw new TaxonomyException(contentstackError.Message, ex) + { + ErrorCode = contentstackError.ErrorCode, + StatusCode = contentstackError.StatusCode, + Errors = contentstackError.Errors + }; } } @@ -153,7 +171,13 @@ public async Task Descendants() } catch (Exception ex) { - throw TaxonomyException.CreateForProcessingError(ex); + var contentstackError = Taxonomy.GetContentstackError(ex); + throw new TaxonomyException(contentstackError.Message, ex) + { + ErrorCode = contentstackError.ErrorCode, + StatusCode = contentstackError.StatusCode, + Errors = contentstackError.Errors + }; } } @@ -172,10 +196,9 @@ private async Task ExecuteRequest(string url, Dictionary mainJson[param.Key] = param.Value; var handler = new HttpRequestHandler(_stack); - var branch = _stack.Config?.Branch ?? "main"; return await handler.ProcessRequest( url, headerAll, mainJson, - Branch: branch, + 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 index d1d0876c..eb64ee62 100644 --- a/Contentstack.Core/Models/TermQuery.cs +++ b/Contentstack.Core/Models/TermQuery.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Contentstack.Core.Internals; using Newtonsoft.Json.Linq; @@ -87,10 +88,9 @@ public async Task> Find() mainJson[param.Key] = param.Value; var handler = new HttpRequestHandler(_stack); - var branch = _stack.Config?.Branch ?? "main"; var result = await handler.ProcessRequest( Url, headerAll, mainJson, - Branch: branch, + Branch: _stack.Config.Branch, timeout: _stack.Config.Timeout, proxy: _stack.Config.Proxy ); @@ -98,7 +98,7 @@ public async Task> Find() var jObject = JObject.Parse(result); var terms = jObject.SelectToken("$.terms")?.ToObject>(_stack.Serializer); var collection = jObject.ToObject>(_stack.Serializer); - collection.Items = terms ?? new List(); + collection.Items = terms ?? Enumerable.Empty(); return collection; } catch (TaxonomyException) @@ -107,7 +107,13 @@ public async Task> Find() } catch (Exception ex) { - throw TaxonomyException.CreateForProcessingError(ex); + var contentstackError = Taxonomy.GetContentstackError(ex); + throw new TaxonomyException(contentstackError.Message, ex) + { + ErrorCode = contentstackError.ErrorCode, + StatusCode = contentstackError.StatusCode, + Errors = contentstackError.Errors + }; } } } From f858c720f960451eb101140697df2b59e8ab162c Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Thu, 16 Jul 2026 08:48:12 +0530 Subject: [PATCH 05/10] fix: for actuall prod stack --- .../Helpers/TestDataHelper.cs | 18 ------------------ .../Taxonomy/TaxonomyLocalisationTest.cs | 8 ++++---- 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/Contentstack.Core.Tests/Helpers/TestDataHelper.cs b/Contentstack.Core.Tests/Helpers/TestDataHelper.cs index b1ea3b13..687f5af5 100644 --- a/Contentstack.Core.Tests/Helpers/TestDataHelper.cs +++ b/Contentstack.Core.Tests/Helpers/TestDataHelper.cs @@ -130,24 +130,6 @@ static TestDataHelper() public static string TaxIndiaState => GetRequiredConfig("TAX_INDIA_STATE"); - /// - /// API key for the taxonomy-publish test stack (gadgets). - /// - public static string TaxPublishApiKey => - GetRequiredConfig("TAX_PUBLISH_API_KEY"); - - /// - /// Delivery token for the taxonomy-publish test stack. - /// - public static string TaxPublishDeliveryToken => - GetRequiredConfig("TAX_PUBLISH_DELIVERY_TOKEN"); - - /// - /// Environment for the taxonomy-publish test stack. - /// - public static string TaxPublishEnvironment => - GetRequiredConfig("TAX_PUBLISH_ENVIRONMENT"); - /// /// UID of the published taxonomy to use in localization tests (e.g. "gadgets"). /// diff --git a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs index ee646dcd..feb311c3 100644 --- a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs +++ b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs @@ -23,16 +23,16 @@ public class TaxonomyLocalisationTest : IntegrationTestBase public TaxonomyLocalisationTest(ITestOutputHelper output) : base(output) { } /// - /// Creates a client scoped to the gadgets taxonomy-publish test stack. + /// Creates a client scoped to the main test stack, which also carries the /// Uses the default CDN host (no custom host override needed). /// private ContentstackClient CreateGadgetsClient() { var options = new ContentstackOptions { - ApiKey = TestDataHelper.TaxPublishApiKey, - DeliveryToken = TestDataHelper.TaxPublishDeliveryToken, - Environment = TestDataHelper.TaxPublishEnvironment + ApiKey = TestDataHelper.ApiKey, + DeliveryToken = TestDataHelper.DeliveryToken, + Environment = TestDataHelper.Environment }; var client = new ContentstackClient(options); client.Plugins.Add(new RequestLoggingPlugin(TestOutput)); From cbe3817b8e45503e59b1c4d6a4a517b081fae300 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Thu, 16 Jul 2026 13:17:24 +0530 Subject: [PATCH 06/10] feat: refactor Taxonomy/Term/TermQuery locale API to fluent chaining convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace Taxonomy.Fetch(string locale) and Term.Fetch(string locale, bool includeFallback) with chainable SetLocale()/IncludeFallback()/AddParam() before Fetch() — matching the convention used by Asset, Entry, and AssetLibrary - Fix Branch ?? "main" fallback across Taxonomy, Term, TermQuery — was injecting branch=main into every CDA request when branching was not configured, causing 422 Branch not found errors - Wire GetContentstackError() into all catch blocks across Taxonomy, Term, TermQuery — replaces TaxonomyException.CreateForProcessingError() which never read the API response body, leaving ErrorCode/StatusCode/Errors unpopulated on 4xx responses - Rename _queryParams → UrlQueries in Term and TermQuery to match Taxonomy and Entry - Fix TermQuery.SetLocale(null) to no-op instead of throwing, consistent with Taxonomy/Term/Entry - Add AddParam() to TermQuery (was missing) - Add class-level XML doc and blocks across Taxonomy, Term, TermQuery Adds 15 new unit tests (Taxonomy.SetLocale/IncludeFallback/AddParam, Term.SetLocale/ IncludeFallback/AddParam, TermQuery.AddParam) — 1016 total, all passing --- CHANGELOG.md | 13 ++ .../Taxonomy/TaxonomyLocalisationTest.cs | 11 +- .../TaxonomyUnitTests.cs | 210 +++++++++++++++++- Contentstack.Core/Models/Taxonomy.cs | 80 ++++++- Contentstack.Core/Models/Term.cs | 93 ++++++-- Contentstack.Core/Models/TermQuery.cs | 44 ++-- 6 files changed, 400 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 525517a0..66630107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +### Version: 2.29.1 +#### Date: Jul-16-2026 + +##### Fix: +- Taxonomy / Term / TermQuery — branch handling + - Removed `?? "main"` fallback on `Config.Branch` in `Taxonomy.Fetch()`, `Term.ExecuteRequest()`, and `TermQuery.Find()`. The fallback was injecting `branch=main` into every CDA request when branching was not configured on the stack, 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. +- Taxonomy / Term — locale/fallback API convention alignment + - `Taxonomy.Fetch()` and `Term.Fetch()` no longer accept `locale` / `includeFallback` parameters. Use the new chainable `SetLocale(string)`, `IncludeFallback()`, and `AddParam(string, string)` methods before calling `Fetch()`, matching the convention used by `Asset`, `Entry`, and `AssetLibrary`. + - Migration: `Taxonomies("uid").Fetch("hi-in")` → `Taxonomies("uid").SetLocale("hi-in").Fetch()` + - Migration: `Term("uid").Fetch("hi-in", includeFallback: true)` → `Term("uid").SetLocale("hi-in").IncludeFallback().Fetch()` + ### Version: 2.29.0 #### Date: Jul-15-2026 diff --git a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs index 89bbd97b..ada1f97b 100644 --- a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs +++ b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs @@ -69,10 +69,11 @@ public async Task Fetch_Taxonomy_WithLocale_ReturnsLocalizedName() var client = CreateGadgetsClient(); - LogAct("Calling Taxonomies(uid).Fetch(locale)"); + LogAct("Calling Taxonomies(uid).SetLocale(locale).Fetch()"); var result = await client .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) - .Fetch(TestDataHelper.TaxPublishLocale); + .SetLocale(TestDataHelper.TaxPublishLocale) + .Fetch(); LogAssert("Verifying response"); Assert.NotNull(result); @@ -183,11 +184,13 @@ public async Task Fetch_SingleTerm_WithLocale_ReturnsLocalizedTerm() LogContext("TermUid", termUid); LogContext("Locale", TestDataHelper.TaxPublishLocale); - LogAct("Calling Term(termUid).Fetch(locale, includeFallback: true)"); + LogAct("Calling Term(termUid).SetLocale(locale).IncludeFallback().Fetch()"); var result = await client .Taxonomies(TestDataHelper.TaxPublishTaxonomyUid) .Term(termUid) - .Fetch(TestDataHelper.TaxPublishLocale, includeFallback: true); + .SetLocale(TestDataHelper.TaxPublishLocale) + .IncludeFallback() + .Fetch(); LogAssert("Verifying response"); Assert.NotNull(result); diff --git a/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs b/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs index ac641d76..a7c49388 100644 --- a/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs +++ b/Contentstack.Core.Unit.Tests/TaxonomyUnitTests.cs @@ -643,7 +643,7 @@ public void TermQuery_SetLocale_SetsLocaleParam() var termQuery = _client.Taxonomies("gadgets").Terms(); termQuery.SetLocale("hi-in"); - var field = typeof(TermQuery).GetField("_queryParams", + var field = typeof(TermQuery).GetField("UrlQueries", BindingFlags.NonPublic | BindingFlags.Instance); var queryParams = (Dictionary)field?.GetValue(termQuery); @@ -652,10 +652,16 @@ public void TermQuery_SetLocale_SetsLocaleParam() } [Fact] - public void TermQuery_SetLocale_WithNull_ThrowsTaxonomyException() + public void TermQuery_SetLocale_WithNull_DoesNotSetParam() { var termQuery = _client.Taxonomies("gadgets").Terms(); - Assert.Throws(() => termQuery.SetLocale(null)); + 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] @@ -672,7 +678,7 @@ public void TermQuery_IncludeFallback_SetsParam() var termQuery = _client.Taxonomies("gadgets").Terms(); termQuery.IncludeFallback(); - var field = typeof(TermQuery).GetField("_queryParams", + var field = typeof(TermQuery).GetField("UrlQueries", BindingFlags.NonPublic | BindingFlags.Instance); var queryParams = (Dictionary)field?.GetValue(termQuery); @@ -687,7 +693,7 @@ public void TermQuery_SetLocale_Then_IncludeFallback_ChainsBoth() .SetLocale("hi-in") .IncludeFallback(); - var field = typeof(TermQuery).GetField("_queryParams", + var field = typeof(TermQuery).GetField("UrlQueries", BindingFlags.NonPublic | BindingFlags.Instance); var queryParams = (Dictionary)field?.GetValue(termQuery); @@ -695,6 +701,200 @@ public void TermQuery_SetLocale_Then_IncludeFallback_ChainsBoth() 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 diff --git a/Contentstack.Core/Models/Taxonomy.cs b/Contentstack.Core/Models/Taxonomy.cs index f4b81f84..df27ac4d 100644 --- a/Contentstack.Core/Models/Taxonomy.cs +++ b/Contentstack.Core/Models/Taxonomy.cs @@ -9,6 +9,12 @@ 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 { @@ -70,12 +76,68 @@ internal Taxonomy(ContentstackClient stack, string uid) : this(stack) #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) @@ -88,6 +150,11 @@ public Term Term(string termUid) /// 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) @@ -98,17 +165,18 @@ public TermQuery Terms() /// /// 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. /// - /// Optional locale code (e.g. "hi-in"). Omit for the master locale. /// 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").Fetch<MyTaxonomy>("hi-in"); + /// 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 System.Threading.Tasks.Task Fetch(string locale = null) + 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."); @@ -122,8 +190,8 @@ public async System.Threading.Tasks.Task Fetch(string locale = null) var mainJson = new Dictionary(); if (Stack.Config?.Environment != null) mainJson["environment"] = Stack.Config.Environment; - if (!string.IsNullOrEmpty(locale)) - mainJson["locale"] = locale; + foreach (var kvp in UrlQueries) + mainJson[kvp.Key] = kvp.Value; var handler = new HttpRequestHandler(Stack); var result = await handler.ProcessRequest( @@ -346,7 +414,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 index a244bf1c..7aeceb05 100644 --- a/Contentstack.Core/Models/Term.cs +++ b/Contentstack.Core/Models/Term.cs @@ -9,12 +9,15 @@ 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}"; @@ -28,30 +31,74 @@ internal Term(ContentstackClient stack, string taxonomyUid, string termUid) _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. /// - /// Optional locale code (e.g. "mr-in"). Omit for the master locale. - /// When true, falls back to the master locale if the term has no localized version. /// The deserialized term object. /// /// - /// var term = await stack.Taxonomies("gadgets").Term("smartwatch").Fetch<MyTerm>(); - /// var localizedTerm = await stack.Taxonomies("gadgets").Term("smartwatch").Fetch<MyTerm>("mr-in"); - /// var withFallback = await stack.Taxonomies("gadgets").Term("laptop").Fetch<MyTerm>("hi-in", includeFallback: true); + /// 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(string locale = null, bool includeFallback = false) + public async Task Fetch() { try { - var queryParams = new Dictionary(); - if (!string.IsNullOrEmpty(locale)) - queryParams["locale"] = locale; - if (includeFallback) - queryParams["include_fallback"] = "true"; - - var result = await ExecuteRequest(BaseUrlPath, queryParams); + var result = await ExecuteRequest(BaseUrlPath, UrlQueries); var jObject = JObject.Parse(result); var token = jObject.SelectToken("$.term"); if (token != null) @@ -75,13 +122,13 @@ public async Task Fetch(string locale = null, bool includeFallback = false } /// - /// Fetches all published, localized versions of this term across every locale. - /// Maps to GET /taxonomies/{uid}/terms/{termUid}/locales. + /// 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<MyLocales>(); + /// var locales = await stack.Taxonomies("gadgets").Term("smartwatch").Locales<JToken>(); /// /// public async Task Locales() @@ -112,13 +159,13 @@ public async Task Locales() } /// - /// Fetches all ancestors of this term up to the root. - /// Maps to GET /taxonomies/{uid}/terms/{termUid}/ancestors. + /// 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<MyTerms>(); + /// var ancestors = await stack.Taxonomies("gadgets").Term("smartwatch").Ancestors<JToken>(); /// /// public async Task Ancestors() @@ -150,12 +197,12 @@ public async Task Ancestors() /// /// Fetches all descendants of this term. - /// Maps to GET /taxonomies/{uid}/terms/{termUid}/descendants. + /// Maps to GET /v3/taxonomies/{uid}/terms/{termUid}/descendants. /// /// The deserialized descendants collection. /// /// - /// var descendants = await stack.Taxonomies("gadgets").Term("smartwatch").Descendants<MyTerms>(); + /// var descendants = await stack.Taxonomies("gadgets").Term("smartwatch").Descendants<JToken>(); /// /// public async Task Descendants() @@ -196,8 +243,8 @@ private async Task ExecuteRequest(string url, Dictionary mainJson["environment"] = _stack.Config.Environment; if (extraParams != null) - foreach (var param in extraParams) - mainJson[param.Key] = param.Value; + foreach (var kvp in extraParams) + mainJson[kvp.Key] = kvp.Value; var handler = new HttpRequestHandler(_stack); return await handler.ProcessRequest( diff --git a/Contentstack.Core/Models/TermQuery.cs b/Contentstack.Core/Models/TermQuery.cs index eb64ee62..79502c1e 100644 --- a/Contentstack.Core/Models/TermQuery.cs +++ b/Contentstack.Core/Models/TermQuery.cs @@ -9,13 +9,14 @@ namespace Contentstack.Core.Models { /// /// Provides a fluent query builder for listing published terms within a taxonomy from the CDA. - /// Supports locale filtering and master-locale fallback. + /// Use , , and to + /// configure the request before calling . /// public class TermQuery { private readonly ContentstackClient _stack; private readonly string _taxonomyUid; - private readonly Dictionary _queryParams = new Dictionary(); + private readonly Dictionary UrlQueries = new Dictionary(); private string Url => $"{_stack.Config.BaseUrl}/taxonomies/{_taxonomyUid}/terms"; @@ -29,8 +30,9 @@ internal TermQuery(ContentstackClient stack, string taxonomyUid) /// /// Filters terms to those published in the specified locale. + /// Passing null or empty is a no-op. /// - /// The locale code (e.g. "hi-in", "en-us"). + /// Locale code (e.g. "hi-in", "en-us"). /// The current for chaining. /// /// @@ -39,15 +41,14 @@ internal TermQuery(ContentstackClient stack, string taxonomyUid) /// public TermQuery SetLocale(string locale) { - if (string.IsNullOrEmpty(locale)) - throw new TaxonomyException("Locale cannot be null or empty."); - _queryParams["locale"] = locale; + if (!string.IsNullOrEmpty(locale)) + UrlQueries["locale"] = locale; return this; } /// - /// When a term is not localized in the requested locale, falls back to the master locale. - /// Must be used together with . + /// 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. /// @@ -57,19 +58,36 @@ public TermQuery SetLocale(string locale) /// public TermQuery IncludeFallback() { - _queryParams["include_fallback"] = "true"; + 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 /taxonomies/{uid}/terms with any configured locale and fallback params. + /// 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) { ... } + /// foreach (var term in result.Items) { ... } /// /// public async Task> Find() @@ -84,8 +102,8 @@ public async Task> Find() if (_stack.Config?.Environment != null) mainJson["environment"] = _stack.Config.Environment; - foreach (var param in _queryParams) - mainJson[param.Key] = param.Value; + foreach (var kvp in UrlQueries) + mainJson[kvp.Key] = kvp.Value; var handler = new HttpRequestHandler(_stack); var result = await handler.ProcessRequest( From f265faf629a2fdd614d73557cdf6b1fd5e10d46e Mon Sep 17 00:00:00 2001 From: raj pandey Date: Thu, 16 Jul 2026 13:26:36 +0530 Subject: [PATCH 07/10] changelog updated --- CHANGELOG.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66630107..af749fe2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,23 +1,22 @@ -### Version: 2.29.1 +### 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` in `Taxonomy.Fetch()`, `Term.ExecuteRequest()`, and `TermQuery.Find()`. The fallback was injecting `branch=main` into every CDA request when branching was not configured on the stack, causing `422 Branch not found` errors. Branch is now passed directly from `Config.Branch`, consistent with `Asset` and `Entry`. + - 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. -- Taxonomy / Term — locale/fallback API convention alignment - - `Taxonomy.Fetch()` and `Term.Fetch()` no longer accept `locale` / `includeFallback` parameters. Use the new chainable `SetLocale(string)`, `IncludeFallback()`, and `AddParam(string, string)` methods before calling `Fetch()`, matching the convention used by `Asset`, `Entry`, and `AssetLibrary`. - - Migration: `Taxonomies("uid").Fetch("hi-in")` → `Taxonomies("uid").SetLocale("hi-in").Fetch()` - - Migration: `Term("uid").Fetch("hi-in", includeFallback: true)` → `Term("uid").SetLocale("hi-in").IncludeFallback().Fetch()` -### Version: 2.29.0 -#### Date: Jul-15-2026 - -##### Feat: -- Taxonomy / Term localisation - - Added `includeFallback` parameter to `Term.Fetch(string locale, bool includeFallback)` so callers can request master-locale fallback on single-term fetch, consistent with `TermQuery.IncludeFallback()` - - Fixed `include_fallback` serialization: the value is now sent as the string `"true"` instead of a C# `bool`, which was being serialized as `"True"` (capital T) and rejected by the CDA +##### 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 From bbb5a451967138a4f5e39c3833fc25d752d22223 Mon Sep 17 00:00:00 2001 From: OMpawar-21 Date: Thu, 16 Jul 2026 13:52:43 +0530 Subject: [PATCH 08/10] Update TaxonomyLocalisationTest.cs --- .../Integration/Taxonomy/TaxonomyLocalisationTest.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs index ada1f97b..223f936f 100644 --- a/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs +++ b/Contentstack.Core.Tests/Integration/Taxonomy/TaxonomyLocalisationTest.cs @@ -23,8 +23,8 @@ public class TaxonomyLocalisationTest : IntegrationTestBase public TaxonomyLocalisationTest(ITestOutputHelper output) : base(output) { } /// - /// Creates a client scoped to the main test stack, which also carries the - /// Uses the default CDN host (no custom host override needed). + /// 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() { @@ -32,7 +32,8 @@ private ContentstackClient CreateGadgetsClient() { ApiKey = TestDataHelper.ApiKey, DeliveryToken = TestDataHelper.DeliveryToken, - Environment = TestDataHelper.Environment + Environment = TestDataHelper.Environment, + Host = TestDataHelper.Host }; var client = new ContentstackClient(options); client.Plugins.Add(new RequestLoggingPlugin(TestOutput)); From eacbd71a4d0a6fc84f434ff2a348fb196b6677fc Mon Sep 17 00:00:00 2001 From: raj pandey Date: Thu, 16 Jul 2026 14:08:46 +0530 Subject: [PATCH 09/10] fix: added internal as stack --- Contentstack.Core/Models/Taxonomy.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Contentstack.Core/Models/Taxonomy.cs b/Contentstack.Core/Models/Taxonomy.cs index df27ac4d..6d04455e 100644 --- a/Contentstack.Core/Models/Taxonomy.cs +++ b/Contentstack.Core/Models/Taxonomy.cs @@ -44,13 +44,12 @@ protected override string _Url } } #endregion - public ContentstackClient Stack + internal new ContentstackClient Stack { get; set; } - #region Internal Constructors internal Taxonomy() From be9e17bed679abb1829e05c791bb392bb97016f1 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Thu, 16 Jul 2026 14:22:33 +0530 Subject: [PATCH 10/10] Test fixed --- Contentstack.Core.Unit.Tests/QueryUnitTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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);