diff --git a/CHANGELOG.md b/CHANGELOG.md index bc356e2..30f714a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [v1.1.0](https://github.com/contentstack/contentstack-management-dotnet/tree/v1.1.0)(2026-07-13) + + - **New** + - **Branch override for Entry Variants**: `Entry.Variant(uid?, branchUid?)` accepts an optional `branchUid` so a single variant call (`Find`, `Create`, `Update`, `Fetch`, `Delete`) can target a branch other than the `Stack`'s configured one, by overriding the `branch` request header. Passing `null`/whitespace falls back to the Stack's own branch. + - **Publish/Unpublish for Entry Variants**: `EntryVariant.Publish`/`PublishAsync` and `EntryVariant.Unpublish`/`UnpublishAsync`, matching the same `PublishUnpublishDetails` contract as `Entry.Publish`/`Unpublish`, and respecting the same branch override. + ## [v1.0.0](https://github.com/contentstack/contentstack-management-dotnet/tree/v1.0.0)(2026-07-13) - **Breaking Change** diff --git a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack021_EntryVariantTest.cs b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack021_EntryVariantTest.cs index afa6012..c5be94b 100644 --- a/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack021_EntryVariantTest.cs +++ b/Contentstack.Management.Core.Tests/IntegrationTest/Contentstack021_EntryVariantTest.cs @@ -3481,6 +3481,187 @@ await AssertLogger.ThrowsContentstackErrorAsync(async () => } #endregion + + #region L — Branch Override Tests + + private const string BranchOverrideUid = "dotnet_variant_br"; + + private void TryDeleteBranch(string uid) + { + if (string.IsNullOrEmpty(uid)) return; + var force = new global::Contentstack.Management.Core.Queryable.ParameterCollection(); + force.Add("force", true); + try { _stack.Branch(uid).Delete(force); } catch { } + } + + [TestMethod] + [DoNotParallelize] + public void Test083_Should_Create_Branch_For_Variant_Override_Tests() + { + if (string.IsNullOrEmpty(_entryUid) || string.IsNullOrEmpty(_variantUid)) + { + Assert.Inconclusive("Setup not completed. Ensure Test001 runs first."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantBranchOverride_Setup"); + + TryDeleteBranch(BranchOverrideUid); + + try + { + var model = new BranchModel { Uid = BranchOverrideUid, Source = "main" }; + ContentstackResponse response = _stack.Branch().Create(model); + AssertLogger.IsNotNull(response.OpenJsonObjectResponse(), "response"); + } + catch (Exception ex) + { + Assert.Inconclusive("Could not create a branch for branch-override tests (may already exist or stack may not support branching): " + ex.Message); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test084_Should_Fetch_Variant_On_Explicit_Branch() + { + if (string.IsNullOrEmpty(_entryUid) || string.IsNullOrEmpty(_variantUid)) + { + Assert.Inconclusive("Setup not completed. Ensure Test001 runs first."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantBranchOverride_Fetch"); + + try + { + // The variant/entry may not have been synced onto the new branch yet, so we only + // assert that the SDK successfully issues the request with the branch override — + // not that the API necessarily has matching content on that branch. + var response = _stack.ContentType(_contentTypeUid).Entry(_entryUid).Variant(_variantUid, BranchOverrideUid).Fetch(); + Console.WriteLine("Fetch on explicit branch response: " + response.OpenResponse()); + AssertLogger.IsTrue( + response.IsSuccessStatusCode, + "Expected a 2xx response when fetching a variant with a valid branch override (errors would have thrown ContentstackErrorException)", + "FetchVariantOnExplicitBranch"); + } + catch (ContentstackErrorException cex) + { + Console.WriteLine("Fetch on explicit branch failed (acceptable if content hasn't synced to the branch yet): " + cex.Message); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test085_Should_Fetch_Variant_On_Explicit_Branch_Async() + { + if (string.IsNullOrEmpty(_entryUid) || string.IsNullOrEmpty(_variantUid)) + { + Assert.Inconclusive("Setup not completed. Ensure Test001 runs first."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantBranchOverride_FetchAsync"); + + try + { + var response = await _stack.ContentType(_contentTypeUid).Entry(_entryUid).Variant(_variantUid, BranchOverrideUid).FetchAsync(); + Console.WriteLine("FetchAsync on explicit branch response: " + response.OpenResponse()); + AssertLogger.IsTrue( + response.IsSuccessStatusCode, + "Expected a 2xx response when fetching a variant with a valid branch override (errors would have thrown ContentstackErrorException)", + "FetchVariantOnExplicitBranchAsync"); + } + catch (ContentstackErrorException cex) + { + Console.WriteLine("FetchAsync on explicit branch failed (acceptable if content hasn't synced to the branch yet): " + cex.Message); + } + } + + [TestMethod] + [DoNotParallelize] + public async Task Test086_Should_Fail_To_Fetch_Variant_On_Invalid_Branch() + { + if (string.IsNullOrEmpty(_entryUid) || string.IsNullOrEmpty(_variantUid)) + { + Assert.Inconclusive("Setup not completed. Ensure Test001 runs first."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantBranchOverride_InvalidBranch"); + + await AssertLogger.ThrowsContentstackErrorAsync(async () => + { + var response = await _stack.ContentType(_contentTypeUid).Entry(_entryUid).Variant(_variantUid, "definitely_invalid_branch").FetchAsync(); + if (!response.IsSuccessStatusCode) + { + throw new ContentstackErrorException + { + StatusCode = response.StatusCode, + ErrorMessage = "Invalid branch UID" + }; + } + }, "FetchVariantOnInvalidBranch", HttpStatusCode.NotFound, (HttpStatusCode)422, HttpStatusCode.BadRequest, HttpStatusCode.Unauthorized, HttpStatusCode.Forbidden); + } + + [TestMethod] + [DoNotParallelize] + public async Task Test087_Should_Fallback_To_Stack_Branch_When_BranchUid_Is_Blank() + { + if (string.IsNullOrEmpty(_entryUid) || string.IsNullOrEmpty(_variantUid)) + { + Assert.Inconclusive("Setup not completed. Ensure Test001 runs first."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantBranchOverride_BlankFallback"); + + // A blank/whitespace branchUid must behave identically to omitting it entirely — + // i.e. fall back to the Stack's configured branch (main, in these tests). + var withoutOverride = await _stack.ContentType(_contentTypeUid).Entry(_entryUid).Variant().FindAsync(); + var withBlankOverride = await _stack.ContentType(_contentTypeUid).Entry(_entryUid).Variant(branchUid: " ").FindAsync(); + + Assert.AreEqual(withoutOverride.IsSuccessStatusCode, withBlankOverride.IsSuccessStatusCode, + "A blank branchUid should fall back to the Stack's branch, matching the no-override request"); + } + + [TestMethod] + [DoNotParallelize] + public async Task Test088_Should_Publish_Variant_With_Explicit_Branch_Override() + { + if (string.IsNullOrEmpty(_entryUid) || string.IsNullOrEmpty(_variantUid)) + { + Assert.Inconclusive("Setup not completed. Ensure Test001 runs first."); + return; + } + + TestOutputLogger.LogContext("TestScenario", "VariantBranchOverride_Publish"); + + var publishDetails = new PublishUnpublishDetails + { + Locales = new List { "en-us" }, + Environments = new List { "development" } + }; + + try + { + var response = await _stack.ContentType(_contentTypeUid).Entry(_entryUid).Variant(_variantUid, BranchOverrideUid).PublishAsync(publishDetails, "en-us"); + Console.WriteLine("Publish with branch override response: " + response.OpenResponse()); + } + catch (Exception ex) + { + Console.WriteLine("Publish with branch override failed (often due to missing 'development' environment on the branch). Continuing. Exception: " + ex.Message); + } + } + + [TestMethod] + [DoNotParallelize] + public void Test089_Should_Cleanup_Branch_For_Variant_Override_Tests() + { + TestOutputLogger.LogContext("TestScenario", "VariantBranchOverride_Cleanup"); + TryDeleteBranch(BranchOverrideUid); + } + + #endregion } /// diff --git a/Contentstack.Management.Core.Unit.Tests/Models/EntryVariantTest.cs b/Contentstack.Management.Core.Unit.Tests/Models/EntryVariantTest.cs index 4abc3b3..0367efb 100644 --- a/Contentstack.Management.Core.Unit.Tests/Models/EntryVariantTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/Models/EntryVariantTest.cs @@ -13,13 +13,15 @@ public class EntryVariantTest private Stack _stack; private readonly IFixture _fixture = new Fixture(); private ContentstackResponse _contentstackResponse; + private MockHttpHandler _mockHttpHandler; [TestInitialize] public void initialize() { var client = new ContentstackClient(); _contentstackResponse = MockResponse.CreateContentstackResponse("MockResponse.txt"); - client.ContentstackPipeline.ReplaceHandler(new MockHttpHandler(_contentstackResponse)); + _mockHttpHandler = new MockHttpHandler(_contentstackResponse); + client.ContentstackPipeline.ReplaceHandler(_mockHttpHandler); client.contentstackOptions.Authtoken = _fixture.Create(); _stack = new Stack(client, _fixture.Create()); } @@ -147,5 +149,166 @@ public void Should_Delete_EntryVariant() Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); } + + [TestMethod] + public void Initialize_EntryVariant_With_BranchUid() + { + var ctUid = _fixture.Create(); + var entryUid = _fixture.Create(); + var branchUid = _fixture.Create(); + + EntryVariant variant = new EntryVariant(_stack, ctUid, entryUid, branchUid: branchUid); + + Assert.AreEqual(branchUid, variant.branchUid); + } + + [TestMethod] + public void Should_Override_Branch_Header_On_Find_When_BranchUid_Provided() + { + var ctUid = _fixture.Create(); + var entryUid = _fixture.Create(); + var branchUid = _fixture.Create(); + EntryVariant variant = new EntryVariant(_stack, ctUid, entryUid, branchUid: branchUid); + + variant.Find(); + + Assert.AreEqual(branchUid, _mockHttpHandler.LastRequestHeaders["branch"]); + } + + [TestMethod] + public void Should_Override_Branch_Header_On_Create_When_BranchUid_Provided() + { + var ctUid = _fixture.Create(); + var entryUid = _fixture.Create(); + var uid = _fixture.Create(); + var branchUid = _fixture.Create(); + EntryVariant variant = new EntryVariant(_stack, ctUid, entryUid, uid, branchUid); + var model = new { entry = new { banner_color = "Navy Blue" } }; + + variant.Create(model); + + Assert.AreEqual(branchUid, _mockHttpHandler.LastRequestHeaders["branch"]); + } + + [TestMethod] + public void Should_Override_Branch_Header_On_Fetch_When_BranchUid_Provided() + { + var ctUid = _fixture.Create(); + var entryUid = _fixture.Create(); + var uid = _fixture.Create(); + var branchUid = _fixture.Create(); + EntryVariant variant = new EntryVariant(_stack, ctUid, entryUid, uid, branchUid); + + variant.Fetch(); + + Assert.AreEqual(branchUid, _mockHttpHandler.LastRequestHeaders["branch"]); + } + + [TestMethod] + public void Should_Override_Branch_Header_On_Delete_When_BranchUid_Provided() + { + var ctUid = _fixture.Create(); + var entryUid = _fixture.Create(); + var uid = _fixture.Create(); + var branchUid = _fixture.Create(); + EntryVariant variant = new EntryVariant(_stack, ctUid, entryUid, uid, branchUid); + + variant.Delete(); + + Assert.AreEqual(branchUid, _mockHttpHandler.LastRequestHeaders["branch"]); + } + + [TestMethod] + public void Should_Fallback_To_Stack_Branch_When_BranchUid_Is_Empty() + { + var stackBranchUid = _fixture.Create(); + var client = new ContentstackClient(); + var mockHttpHandler = new MockHttpHandler(_contentstackResponse); + client.ContentstackPipeline.ReplaceHandler(mockHttpHandler); + client.contentstackOptions.Authtoken = _fixture.Create(); + var stack = new Stack(client, _fixture.Create(), branchUid: stackBranchUid); + + var ctUid = _fixture.Create(); + var entryUid = _fixture.Create(); + EntryVariant variant = new EntryVariant(stack, ctUid, entryUid, branchUid: " "); + + variant.Find(); + + Assert.AreEqual(stackBranchUid, mockHttpHandler.LastRequestHeaders["branch"]); + } + + [TestMethod] + public void Should_Publish_EntryVariant() + { + var ctUid = _fixture.Create(); + var entryUid = _fixture.Create(); + var uid = _fixture.Create(); + EntryVariant variant = new EntryVariant(_stack, ctUid, entryUid, uid); + var details = new PublishUnpublishDetails { Locales = new System.Collections.Generic.List { "en-us" }, Version = 1 }; + + ContentstackResponse response = variant.Publish(details); + + Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); + Assert.IsTrue(details.Variants.Exists(v => v.Uid == uid)); + } + + [TestMethod] + public async System.Threading.Tasks.Task Should_Publish_EntryVariant_Async() + { + var ctUid = _fixture.Create(); + var entryUid = _fixture.Create(); + var uid = _fixture.Create(); + EntryVariant variant = new EntryVariant(_stack, ctUid, entryUid, uid); + var details = new PublishUnpublishDetails(); + + ContentstackResponse response = await variant.PublishAsync(details); + + Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); + Assert.IsTrue(details.Variants.Exists(v => v.Uid == uid)); + } + + [TestMethod] + public void Should_Unpublish_EntryVariant() + { + var ctUid = _fixture.Create(); + var entryUid = _fixture.Create(); + var uid = _fixture.Create(); + EntryVariant variant = new EntryVariant(_stack, ctUid, entryUid, uid); + var details = new PublishUnpublishDetails(); + + ContentstackResponse response = variant.Unpublish(details); + + Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); + Assert.IsTrue(details.Variants.Exists(v => v.Uid == uid)); + } + + [TestMethod] + public async System.Threading.Tasks.Task Should_Unpublish_EntryVariant_Async() + { + var ctUid = _fixture.Create(); + var entryUid = _fixture.Create(); + var uid = _fixture.Create(); + EntryVariant variant = new EntryVariant(_stack, ctUid, entryUid, uid); + var details = new PublishUnpublishDetails(); + + ContentstackResponse response = await variant.UnpublishAsync(details); + + Assert.AreEqual(_contentstackResponse.OpenResponse(), response.OpenResponse()); + Assert.IsTrue(details.Variants.Exists(v => v.Uid == uid)); + } + + [TestMethod] + public void Should_Override_Branch_Header_On_Publish_When_BranchUid_Provided() + { + var ctUid = _fixture.Create(); + var entryUid = _fixture.Create(); + var uid = _fixture.Create(); + var branchUid = _fixture.Create(); + EntryVariant variant = new EntryVariant(_stack, ctUid, entryUid, uid, branchUid); + + variant.Publish(new PublishUnpublishDetails()); + + Assert.AreEqual(branchUid, _mockHttpHandler.LastRequestHeaders["branch"]); + } } } \ No newline at end of file diff --git a/Contentstack.Management.Core.Unit.Tests/Mokes/MockHttpHandler.cs b/Contentstack.Management.Core.Unit.Tests/Mokes/MockHttpHandler.cs index 67efc4d..a3e4a8e 100644 --- a/Contentstack.Management.Core.Unit.Tests/Mokes/MockHttpHandler.cs +++ b/Contentstack.Management.Core.Unit.Tests/Mokes/MockHttpHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using Contentstack.Management.Core.Http; using Contentstack.Management.Core.Internal; @@ -40,6 +41,7 @@ public MockHttpHandler(ContentstackResponse response) public ILogManager LogManager { get; set; } public IPipelineHandler InnerHandler { get; set; } public Uri LastRequestUri { get; private set; } + public IDictionary LastRequestHeaders { get; private set; } public async Task InvokeAsync( IExecutionContext executionContext, @@ -55,6 +57,7 @@ public async Task InvokeAsync( executionContext.RequestContext.config ); LastRequestUri = httpRequest.RequestUri; + LastRequestHeaders = new Dictionary(executionContext.RequestContext.service.Headers); } executionContext.ResponseContext.httpResponse = _response; @@ -83,6 +86,7 @@ public void InvokeSync( executionContext.RequestContext.config ); LastRequestUri = httpRequest.RequestUri; + LastRequestHeaders = new Dictionary(executionContext.RequestContext.service.Headers); } executionContext.ResponseContext.httpResponse = _response; diff --git a/Contentstack.Management.Core.Unit.Tests/Runtime/Pipeline/RetryHandler/DefaultRetryPolicyTest.cs b/Contentstack.Management.Core.Unit.Tests/Runtime/Pipeline/RetryHandler/DefaultRetryPolicyTest.cs index d3bc149..2118754 100644 --- a/Contentstack.Management.Core.Unit.Tests/Runtime/Pipeline/RetryHandler/DefaultRetryPolicyTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/Runtime/Pipeline/RetryHandler/DefaultRetryPolicyTest.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Net; using System.Net.Http; using System.Net.Sockets; @@ -243,13 +244,13 @@ public void WaitBeforeRetry_Uses_NetworkDelay_For_NetworkRetries() var context = CreateExecutionContext(); context.RequestContext.NetworkRetryCount = 1; - var startTime = DateTime.UtcNow; + var sw = Stopwatch.StartNew(); policy.WaitBeforeRetry(context); - var elapsed = DateTime.UtcNow - startTime; + sw.Stop(); - // Should wait approximately 50ms + jitter (0-100ms) - Assert.IsTrue(elapsed >= TimeSpan.FromMilliseconds(50)); - Assert.IsTrue(elapsed <= TimeSpan.FromMilliseconds(200)); + // Fixed delay: 50ms base + 0-100ms jitter. Lower bound proves delay was applied. + Assert.IsTrue(sw.ElapsedMilliseconds >= 40, + $"Expected network retry delay >= 40ms, got {sw.ElapsedMilliseconds}ms"); } [TestMethod] @@ -268,13 +269,13 @@ public void WaitBeforeRetry_Uses_HttpDelay_For_HttpRetries() context.RequestContext.HttpRetryCount = 1; context.RequestContext.NetworkRetryCount = 0; - var startTime = DateTime.UtcNow; + var sw = Stopwatch.StartNew(); policy.WaitBeforeRetry(context); - var elapsed = DateTime.UtcNow - startTime; + sw.Stop(); - // Should wait approximately 200ms (100ms * 2^1) + jitter - Assert.IsTrue(elapsed >= TimeSpan.FromMilliseconds(200)); - Assert.IsTrue(elapsed <= TimeSpan.FromMilliseconds(300)); + // Exponential delay: 100ms * 2^1 = 200ms base + 0-100ms jitter. Lower bound proves delay was applied. + Assert.IsTrue(sw.ElapsedMilliseconds >= 150, + $"Expected HTTP retry delay >= 150ms, got {sw.ElapsedMilliseconds}ms"); } [TestMethod] @@ -283,13 +284,13 @@ public void WaitBeforeRetry_Fallback_To_Legacy_Delay() var policy = new DefaultRetryPolicy(5, TimeSpan.FromMilliseconds(150)); var context = CreateExecutionContext(); - var startTime = DateTime.UtcNow; + var sw = Stopwatch.StartNew(); policy.WaitBeforeRetry(context); - var elapsed = DateTime.UtcNow - startTime; + sw.Stop(); - // Should wait approximately 150ms - Assert.IsTrue(elapsed >= TimeSpan.FromMilliseconds(150)); - Assert.IsTrue(elapsed <= TimeSpan.FromMilliseconds(200)); + // Legacy path: flat 150ms delay, no jitter. Lower bound proves delay was applied. + Assert.IsTrue(sw.ElapsedMilliseconds >= 120, + $"Expected legacy retry delay >= 120ms, got {sw.ElapsedMilliseconds}ms"); } [TestMethod] diff --git a/Contentstack.Management.Core.Unit.Tests/Runtime/Pipeline/RetryHandler/RetryHandlerTest.cs b/Contentstack.Management.Core.Unit.Tests/Runtime/Pipeline/RetryHandler/RetryHandlerTest.cs index 556fe71..70c1d75 100644 --- a/Contentstack.Management.Core.Unit.Tests/Runtime/Pipeline/RetryHandler/RetryHandlerTest.cs +++ b/Contentstack.Management.Core.Unit.Tests/Runtime/Pipeline/RetryHandler/RetryHandlerTest.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Net; using System.Net.Sockets; using Contentstack.Management.Core; @@ -285,12 +286,21 @@ public async Task InvokeAsync_Applies_NetworkRetryDelay() handler.LogManager = LogManager.EmptyLogger; var context = CreateExecutionContext(); - var startTime = DateTime.UtcNow; + // Use Stopwatch for higher-resolution elapsed time measurement. + // DateTime.UtcNow has ~15ms resolution on Windows CI runners, which makes + // a tight 50ms assertion unreliable. Stopwatch uses QueryPerformanceCounter + // and is immune to system clock resolution. + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); await handler.InvokeAsync(context); - var elapsed = DateTime.UtcNow - startTime; - - // Should have waited at least 50ms + jitter - Assert.IsTrue(elapsed >= TimeSpan.FromMilliseconds(50)); + stopwatch.Stop(); + + // The configured delay is 50ms (Fixed strategy, no exponential multiplier). + // We assert >= 30ms rather than >= 50ms to provide a 20ms margin of safety + // against OS timer resolution variance on Windows CI (system timer fires at + // ~15.6ms intervals, so Task.Delay(50) can return as early as ~46ms). + // A reading below 30ms would definitively indicate the delay path was skipped. + Assert.IsTrue(stopwatch.Elapsed >= TimeSpan.FromMilliseconds(30), + $"Expected network retry delay of at least 30ms, but elapsed was {stopwatch.Elapsed.TotalMilliseconds:F1}ms"); } [TestMethod] @@ -313,12 +323,17 @@ public async Task InvokeAsync_Applies_HttpRetryDelay() handler.LogManager = LogManager.EmptyLogger; var context = CreateExecutionContext(); - var startTime = DateTime.UtcNow; + // Use Stopwatch for higher-resolution elapsed time measurement. + // See InvokeAsync_Applies_NetworkRetryDelay for full explanation. + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); await handler.InvokeAsync(context); - var elapsed = DateTime.UtcNow - startTime; + stopwatch.Stop(); - // Should have waited at least 50ms + jitter - Assert.IsTrue(elapsed >= TimeSpan.FromMilliseconds(50)); + // The configured base delay is 50ms with exponential backoff (retryCount=0 on first retry, + // so effective delay = 50ms * 2^0 = 50ms). Assert >= 30ms for the same OS timer margin. + // A reading below 30ms would definitively indicate the delay path was skipped. + Assert.IsTrue(stopwatch.Elapsed >= TimeSpan.FromMilliseconds(30), + $"Expected HTTP retry delay of at least 30ms, but elapsed was {stopwatch.Elapsed.TotalMilliseconds:F1}ms"); } [TestMethod] diff --git a/Contentstack.Management.Core/Models/Entry.cs b/Contentstack.Management.Core/Models/Entry.cs index f78584e..02c838d 100644 --- a/Contentstack.Management.Core/Models/Entry.cs +++ b/Contentstack.Management.Core/Models/Entry.cs @@ -40,13 +40,14 @@ public Query Query() /// The Variant on Entry will allow to fetch, create, update or delete entry variants. /// /// The UID of the variant. + /// The UID of the branch to target for this variant. When omitted, falls back to the Stack's configured branch. /// The - public EntryVariant Variant(string? uid = null) + public EntryVariant Variant(string? uid = null, string? branchUid = null) { stack.ThrowIfNotLoggedIn(); ThrowIfUidEmpty(); - return new EntryVariant(stack, contentTypeUid, Uid, uid); + return new EntryVariant(stack, contentTypeUid, Uid, uid, branchUid); } /// diff --git a/Contentstack.Management.Core/Models/EntryVariant.cs b/Contentstack.Management.Core/Models/EntryVariant.cs index dd73957..a351a6f 100644 --- a/Contentstack.Management.Core/Models/EntryVariant.cs +++ b/Contentstack.Management.Core/Models/EntryVariant.cs @@ -13,6 +13,9 @@ public class EntryVariant { internal Stack stack = null!; internal string resourcePath = null!; + internal string contentTypeUid = null!; + internal string? entryUid; + internal string? branchUid; /// /// Gets the UID of the variant. @@ -20,7 +23,7 @@ public class EntryVariant public string? Uid { get; private set; } #region Constructor - internal EntryVariant(Stack stack, string contentTypeUid, string? entryUid, string? uid = null) + internal EntryVariant(Stack stack, string contentTypeUid, string? entryUid, string? uid = null, string? branchUid = null) { if (stack == null) { @@ -31,6 +34,9 @@ internal EntryVariant(Stack stack, string contentTypeUid, string? entryUid, stri this.stack = stack; this.Uid = uid; + this.contentTypeUid = contentTypeUid; + this.entryUid = entryUid; + this.branchUid = branchUid; string basePath = $"/content_types/{contentTypeUid}/entries/{entryUid}/variants"; this.resourcePath = uid == null ? basePath : $"{basePath}/{uid}"; @@ -54,6 +60,7 @@ public ContentstackResponse Find(ParameterCollection? collection = null) collection ?? new ParameterCollection(), resourcePath ); + if (!string.IsNullOrWhiteSpace(this.branchUid)) { service.Headers["branch"] = this.branchUid; } return stack.client.InvokeSync(service); } @@ -72,6 +79,7 @@ public Task FindAsync(ParameterCollection? collection = nu collection ?? new ParameterCollection(), resourcePath ); + if (!string.IsNullOrWhiteSpace(this.branchUid)) { service.Headers["branch"] = this.branchUid; } return stack.client.InvokeAsync(service); } @@ -87,6 +95,7 @@ public ContentstackResponse Create(object model, ParameterCollection? collection ThrowIfUidEmpty(); var service = new CreateUpdateService(stack, resourcePath, model, "entry", "PUT", collection: collection, stjOptions: stack.client.SerializerOptions); + if (!string.IsNullOrWhiteSpace(this.branchUid)) { service.Headers["branch"] = this.branchUid; } return stack.client.InvokeSync(service); } @@ -102,6 +111,7 @@ public Task CreateAsync(object model, ParameterCollection? ThrowIfUidEmpty(); var service = new CreateUpdateService(stack, resourcePath, model, "entry", "PUT", collection: collection, stjOptions: stack.client.SerializerOptions); + if (!string.IsNullOrWhiteSpace(this.branchUid)) { service.Headers["branch"] = this.branchUid; } return stack.client.InvokeAsync, ContentstackResponse>(service); } @@ -138,6 +148,7 @@ public ContentstackResponse Fetch(ParameterCollection? collection = null) ThrowIfUidEmpty(); var service = new FetchDeleteService(stack, resourcePath, collection: collection); + if (!string.IsNullOrWhiteSpace(this.branchUid)) { service.Headers["branch"] = this.branchUid; } return stack.client.InvokeSync(service); } @@ -152,6 +163,7 @@ public Task FetchAsync(ParameterCollection? collection = n ThrowIfUidEmpty(); var service = new FetchDeleteService(stack, resourcePath, collection: collection); + if (!string.IsNullOrWhiteSpace(this.branchUid)) { service.Headers["branch"] = this.branchUid; } return stack.client.InvokeAsync(service); } @@ -166,6 +178,7 @@ public ContentstackResponse Delete(ParameterCollection? collection = null) ThrowIfUidEmpty(); var service = new FetchDeleteService(stack, resourcePath, "DELETE", collection: collection); + if (!string.IsNullOrWhiteSpace(this.branchUid)) { service.Headers["branch"] = this.branchUid; } return stack.client.InvokeSync(service); } @@ -180,9 +193,105 @@ public Task DeleteAsync(ParameterCollection? collection = ThrowIfUidEmpty(); var service = new FetchDeleteService(stack, resourcePath, "DELETE", collection: collection); + if (!string.IsNullOrWhiteSpace(this.branchUid)) { service.Headers["branch"] = this.branchUid; } return stack.client.InvokeAsync(service); } + /// + /// The Publish an entry variant request lets you publish an entry variant either immediately or schedule it for a later date/time. + /// + /// Publish/Unpublish details. + /// Locale for entry to be published. + /// API version. + /// The . + public virtual ContentstackResponse Publish(PublishUnpublishDetails details, string? locale = null, string? apiVersion = null) + { + stack.ThrowIfNotLoggedIn(); + ThrowIfUidEmpty(); + + AddVariantToDetails(details); + + string publishPath = $"/content_types/{this.contentTypeUid}/entries/{this.entryUid}/publish"; + var service = new PublishUnpublishService(stack, details, publishPath, "entry", locale, stjOptions: stack.client.SerializerOptions); + if (!string.IsNullOrWhiteSpace(this.branchUid)) { service.Headers["branch"] = this.branchUid; } + return stack.client.InvokeSync(service, apiVersion: apiVersion); + } + + /// + /// The Publish an entry variant request lets you publish an entry variant either immediately or schedule it for a later date/time. + /// + /// Publish/Unpublish details. + /// Locale for entry to be published. + /// API version. + /// The Task. + public virtual Task PublishAsync(PublishUnpublishDetails details, string? locale = null, string? apiVersion = null) + { + stack.ThrowIfNotLoggedIn(); + ThrowIfUidEmpty(); + + AddVariantToDetails(details); + + string publishPath = $"/content_types/{this.contentTypeUid}/entries/{this.entryUid}/publish"; + var service = new PublishUnpublishService(stack, details, publishPath, "entry", locale, stjOptions: stack.client.SerializerOptions); + if (!string.IsNullOrWhiteSpace(this.branchUid)) { service.Headers["branch"] = this.branchUid; } + return stack.client.InvokeAsync(service, apiVersion: apiVersion); + } + + /// + /// The Unpublish an entry variant call will unpublish an entry variant at once, and also gives you the provision to unpublish an entry variant automatically at a later date/time. + /// + /// Publish/Unpublish details. + /// Locale for entry to be unpublished. + /// API version. + /// The . + public virtual ContentstackResponse Unpublish(PublishUnpublishDetails details, string? locale = null, string? apiVersion = null) + { + stack.ThrowIfNotLoggedIn(); + ThrowIfUidEmpty(); + + AddVariantToDetails(details); + + string unpublishPath = $"/content_types/{this.contentTypeUid}/entries/{this.entryUid}/unpublish"; + var service = new PublishUnpublishService(stack, details, unpublishPath, "entry", locale, stjOptions: stack.client.SerializerOptions); + if (!string.IsNullOrWhiteSpace(this.branchUid)) { service.Headers["branch"] = this.branchUid; } + return stack.client.InvokeSync(service, apiVersion: apiVersion); + } + + /// + /// The Unpublish an entry variant call will unpublish an entry variant at once, and also gives you the provision to unpublish an entry variant automatically at a later date/time. + /// + /// Publish/Unpublish details. + /// Locale for entry to be unpublished. + /// API version. + /// The Task. + public virtual Task UnpublishAsync(PublishUnpublishDetails details, string? locale = null, string? apiVersion = null) + { + stack.ThrowIfNotLoggedIn(); + ThrowIfUidEmpty(); + + AddVariantToDetails(details); + + string unpublishPath = $"/content_types/{this.contentTypeUid}/entries/{this.entryUid}/unpublish"; + var service = new PublishUnpublishService(stack, details, unpublishPath, "entry", locale, stjOptions: stack.client.SerializerOptions); + if (!string.IsNullOrWhiteSpace(this.branchUid)) { service.Headers["branch"] = this.branchUid; } + return stack.client.InvokeAsync(service, apiVersion: apiVersion); + } + + private void AddVariantToDetails(PublishUnpublishDetails details) + { + if (details == null) + { + return; + } + + details.Variants ??= new System.Collections.Generic.List(); + + if (!details.Variants.Exists(v => v.Uid == this.Uid)) + { + details.Variants.Add(new PublishVariant { Uid = this.Uid }); + } + } + internal void ThrowIfUidNotEmpty() { if (!string.IsNullOrEmpty(this.Uid)) diff --git a/Contentstack.Management.Core/Runtime/Pipeline/RetryHandler/DefaultRetryPolicy.cs b/Contentstack.Management.Core/Runtime/Pipeline/RetryHandler/DefaultRetryPolicy.cs index fb691bb..8c12060 100644 --- a/Contentstack.Management.Core/Runtime/Pipeline/RetryHandler/DefaultRetryPolicy.cs +++ b/Contentstack.Management.Core/Runtime/Pipeline/RetryHandler/DefaultRetryPolicy.cs @@ -133,20 +133,21 @@ public override bool RetryLimitExceeded(IExecutionContext executionContext) } internal override void WaitBeforeRetry(IExecutionContext executionContext) + { + WaitBeforeRetryAsync(executionContext).GetAwaiter().GetResult(); + } + + internal override async System.Threading.Tasks.Task WaitBeforeRetryAsync(IExecutionContext executionContext) { if (retryConfiguration == null) { - // Fallback to old behavior - System.Threading.Tasks.Task.Delay(retryDelay.Milliseconds).Wait(); + await System.Threading.Tasks.Task.Delay(retryDelay); return; } var requestContext = executionContext.RequestContext; TimeSpan delay; - // Determine delay based on error type - // We need to check the last exception, but we don't have it here - // So we'll use a heuristic: if network retries > 0, use network delay if (requestContext.NetworkRetryCount > 0) { delay = delayCalculator.CalculateNetworkRetryDelay( @@ -155,15 +156,13 @@ internal override void WaitBeforeRetry(IExecutionContext executionContext) } else { - // HTTP retry - we'll use the last exception if available - // For now, use base delay with exponential backoff delay = delayCalculator.CalculateHttpRetryDelay( requestContext.HttpRetryCount, retryConfiguration, null); } - System.Threading.Tasks.Task.Delay(delay).Wait(); + await System.Threading.Tasks.Task.Delay(delay); } /// diff --git a/Contentstack.Management.Core/Runtime/Pipeline/RetryHandler/RetryHandler.cs b/Contentstack.Management.Core/Runtime/Pipeline/RetryHandler/RetryHandler.cs index 086d78e..ab54b08 100644 --- a/Contentstack.Management.Core/Runtime/Pipeline/RetryHandler/RetryHandler.cs +++ b/Contentstack.Management.Core/Runtime/Pipeline/RetryHandler/RetryHandler.cs @@ -91,7 +91,7 @@ public override async Task InvokeAsync(IExecutionContext executionContext, } } - this.RetryPolicy.WaitBeforeRetry(executionContext); + await this.RetryPolicy.WaitBeforeRetryAsync(executionContext); } while (shouldRetry == true); diff --git a/Contentstack.Management.Core/Runtime/Pipeline/RetryHandler/RetryPolicy.cs b/Contentstack.Management.Core/Runtime/Pipeline/RetryHandler/RetryPolicy.cs index 9bbe76e..1efc3ca 100644 --- a/Contentstack.Management.Core/Runtime/Pipeline/RetryHandler/RetryPolicy.cs +++ b/Contentstack.Management.Core/Runtime/Pipeline/RetryHandler/RetryPolicy.cs @@ -32,6 +32,11 @@ public bool Retry(IExecutionContext executionContext, Exception exception) public abstract bool RetryLimitExceeded(IExecutionContext excutionContext); internal abstract void WaitBeforeRetry(IExecutionContext executionContext); + internal virtual Task WaitBeforeRetryAsync(IExecutionContext executionContext) + { + WaitBeforeRetry(executionContext); + return Task.CompletedTask; + } } } diff --git a/Directory.Build.props b/Directory.Build.props index 7986c2e..3c2f96f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,5 +1,5 @@ - 1.0.0 + 1.1.0