From 7635ea79f687535347cd034750afbddb5e454803 Mon Sep 17 00:00:00 2001 From: tomasw_kontent Date: Tue, 25 Feb 2025 18:04:41 +0100 Subject: [PATCH 1/3] Add used-in support --- .../DeliveryClientExtensions.cs | 27 +- .../IDeliveryClient.cs | 16 ++ .../UsedIn/IUsedInItem.cs | 14 + .../UsedIn/IUsedInItemSystemAttributes.cs | 33 +++ .../DeliveryClientCache.cs | 22 ++ .../DeliveryObservableProxyTests.cs | 34 +++ .../Fixtures/used_in.json | 30 ++ .../Kontent.Ai.Delivery.Rx.Tests.csproj | 6 + .../DeliveryObservableProxy.cs | 67 ++++- .../DeliveryClientTests.cs | 259 ++++++++++++++++++ .../Fixtures/DeliveryClient/used_in.json | 82 ++++++ .../DeliveryClient/used_in_batch_1.json | 69 +++++ .../DeliveryClient/used_in_batch_2.json | 17 ++ .../Kontent.Ai.Delivery.Tests.csproj | 9 + Kontent.Ai.Delivery/DeliveryClient.cs | 54 ++++ .../UsedIn/DeliveryUsedInItems.cs | 56 ++++ .../UsedIn/DeliveryUsedInResponse.cs | 29 ++ Kontent.Ai.Delivery/UsedIn/UsedInItem.cs | 21 ++ .../UsedIn/UsedInItemSystemAttributes.cs | 56 ++++ .../DeliveryEndpointUrlBuilderTests.cs | 26 ++ .../Delivery/DeliveryEndpointUrlBuilder.cs | 30 +- 21 files changed, 942 insertions(+), 15 deletions(-) create mode 100644 Kontent.Ai.Delivery.Abstractions/UsedIn/IUsedInItem.cs create mode 100644 Kontent.Ai.Delivery.Abstractions/UsedIn/IUsedInItemSystemAttributes.cs create mode 100644 Kontent.Ai.Delivery.Rx.Tests/Fixtures/used_in.json create mode 100644 Kontent.Ai.Delivery.Tests/Fixtures/DeliveryClient/used_in.json create mode 100644 Kontent.Ai.Delivery.Tests/Fixtures/DeliveryClient/used_in_batch_1.json create mode 100644 Kontent.Ai.Delivery.Tests/Fixtures/DeliveryClient/used_in_batch_2.json create mode 100644 Kontent.Ai.Delivery/UsedIn/DeliveryUsedInItems.cs create mode 100644 Kontent.Ai.Delivery/UsedIn/DeliveryUsedInResponse.cs create mode 100644 Kontent.Ai.Delivery/UsedIn/UsedInItem.cs create mode 100644 Kontent.Ai.Delivery/UsedIn/UsedInItemSystemAttributes.cs diff --git a/Kontent.Ai.Delivery.Abstractions/DeliveryClientExtensions.cs b/Kontent.Ai.Delivery.Abstractions/DeliveryClientExtensions.cs index 9a97af80..3adc6caf 100644 --- a/Kontent.Ai.Delivery.Abstractions/DeliveryClientExtensions.cs +++ b/Kontent.Ai.Delivery.Abstractions/DeliveryClientExtensions.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading.Tasks; namespace Kontent.Ai.Delivery.Abstractions { @@ -87,5 +88,29 @@ public static Task PostSyncInitAsync(this IDeliveryCl { return client.PostSyncInitAsync(parameters); } + + /// + /// Returns a feed that is used to traverse through strongly typed parent content items matching the optional filtering parameters. + /// + /// An instance of the + /// The codename of a content item. + /// An array that contains zero or more query parameters, for example, for filtering or ordering. + /// The instance that can be used to enumerate through content item parents for the specified item codename. If no query parameters are specified, default language parents are enumerated. + public static IDeliveryItemsFeed GetItemUsedIn(this IDeliveryClient client, string codename, params IQueryParameter[] parameters) + { + return client.GetItemUsedIn(codename, parameters); + } + + /// + /// Returns a feed that is used to traverse through strongly typed parent content items matching the optional filtering parameters. + /// + /// An instance of the + /// The codename of an asset. + /// An array that contains zero or more query parameters, for example, for filtering, or ordering. + /// The instance that can be used to enumerate through asset parents for the specified asset codename. If no query parameters are specified, default language parents are enumerated. + public static IDeliveryItemsFeed GetAssetUsedIn(this IDeliveryClient client, string codename, params IQueryParameter[] parameters) + { + return client.GetAssetUsedIn(codename, parameters); + } } } diff --git a/Kontent.Ai.Delivery.Abstractions/IDeliveryClient.cs b/Kontent.Ai.Delivery.Abstractions/IDeliveryClient.cs index 5abbf94a..6b76cfd0 100644 --- a/Kontent.Ai.Delivery.Abstractions/IDeliveryClient.cs +++ b/Kontent.Ai.Delivery.Abstractions/IDeliveryClient.cs @@ -89,5 +89,21 @@ public interface IDeliveryClient /// /// The instance that represents the sync response that contains collection of delta updates and continuation token needed for further sync execution. Task GetSyncAsync(string continuationToken); + + /// + /// Returns a feed that is used to traverse through strongly typed parent content items matching the optional filtering parameters. + /// + /// The codename of a content item. + /// A collection of query parameters, for example, for filtering or ordering. + /// The instance that can be used to enumerate through content item parents for the specified item codename. If no query parameters are specified, default language parents are enumerated. + public IDeliveryItemsFeed GetItemUsedIn(string codename, IEnumerable parameters = null); + + /// + /// Returns a feed that is used to traverse through strongly typed parent content items matching the optional filtering parameters. + /// + /// The codename of an asset. + /// A collection of query parameters, for example, for filtering or ordering. + /// The instance that can be used to enumerate through asset parents for the specified asset codename. If no query parameters are specified, default language parents are enumerated. + public IDeliveryItemsFeed GetAssetUsedIn(string codename, IEnumerable parameters = null); } } \ No newline at end of file diff --git a/Kontent.Ai.Delivery.Abstractions/UsedIn/IUsedInItem.cs b/Kontent.Ai.Delivery.Abstractions/UsedIn/IUsedInItem.cs new file mode 100644 index 00000000..7cc02327 --- /dev/null +++ b/Kontent.Ai.Delivery.Abstractions/UsedIn/IUsedInItem.cs @@ -0,0 +1,14 @@ +using System; + +namespace Kontent.Ai.Delivery.Abstractions; + +/// +/// Represents a parent content item. +/// +public interface IUsedInItem +{ + /// + /// Represents system attributes of a parent content item. + /// + public IUsedInItemSystemAttributes System { get; } +} \ No newline at end of file diff --git a/Kontent.Ai.Delivery.Abstractions/UsedIn/IUsedInItemSystemAttributes.cs b/Kontent.Ai.Delivery.Abstractions/UsedIn/IUsedInItemSystemAttributes.cs new file mode 100644 index 00000000..205e4456 --- /dev/null +++ b/Kontent.Ai.Delivery.Abstractions/UsedIn/IUsedInItemSystemAttributes.cs @@ -0,0 +1,33 @@ +namespace Kontent.Ai.Delivery.Abstractions +{ + /// + /// Represents system attributes of a parent content item. + /// + public interface IUsedInItemSystemAttributes : ISystemAttributes + { + /// + /// Gets the language of the content item. + /// + string Language { get; } + + /// + /// Gets the codename of the content type, for example "article". + /// + string Type { get; } + + /// + /// Gets the codename of the content collection to which the content item belongs. + /// + public string Collection { get; } + + /// + /// Gets the codename of the workflow which the content item is assigned to. + /// + public string Workflow { get; } + + /// + /// Gets the codename of the workflow step which the content item is assigned to. + /// + public string WorkflowStep { get; } + } +} diff --git a/Kontent.Ai.Delivery.Caching/DeliveryClientCache.cs b/Kontent.Ai.Delivery.Caching/DeliveryClientCache.cs index 6cf19da4..3ea988ad 100644 --- a/Kontent.Ai.Delivery.Caching/DeliveryClientCache.cs +++ b/Kontent.Ai.Delivery.Caching/DeliveryClientCache.cs @@ -175,5 +175,27 @@ public Task GetSyncAsync(string continuationToken) { return _deliveryClient.GetSyncAsync(continuationToken); } + + /// + /// Returns a feed that is used to traverse through strongly typed parent content items matching the optional filtering parameters. + /// + /// The codename of a content item. + /// A collection of query parameters, for example, for filtering or ordering. + /// The instance that can be used to enumerate through content item parents for the specified item codename. If no query parameters are specified, default language parents are enumerated. + public IDeliveryItemsFeed GetItemUsedIn(string codename, IEnumerable parameters = null) + { + return _deliveryClient.GetItemUsedIn(codename, parameters); + } + + /// + /// Returns a feed that is used to traverse through strongly typed parent content items matching the optional filtering parameters. + /// + /// The codename of an asset. + /// A collection of query parameters, for example, for filtering or ordering. + /// The instance that can be used to enumerate through asset parents for the specified asset codename. If no query parameters are specified, default language parents are enumerated. + public IDeliveryItemsFeed GetAssetUsedIn(string codename, IEnumerable parameters = null) + { + return _deliveryClient.GetAssetUsedIn(codename, parameters); + } } } diff --git a/Kontent.Ai.Delivery.Rx.Tests/DeliveryObservableProxyTests.cs b/Kontent.Ai.Delivery.Rx.Tests/DeliveryObservableProxyTests.cs index 5101619e..4c1c288f 100644 --- a/Kontent.Ai.Delivery.Rx.Tests/DeliveryObservableProxyTests.cs +++ b/Kontent.Ai.Delivery.Rx.Tests/DeliveryObservableProxyTests.cs @@ -21,6 +21,7 @@ namespace Kontent.Ai.Delivery.Rx.Tests public class DeliveryObservableProxyTests { private const string BEVERAGES_IDENTIFIER = "coffee_beverages_explained"; + private const string ASSET_CODENAME = "asset_codename"; readonly string _guid; readonly string _baseUrl; readonly MockHttpMessageHandler _mockHttp; @@ -180,6 +181,27 @@ public void LanguagesRetrieved() Assert.NotEmpty(languages); Assert.All(languages, language => Assert.NotNull(language.System)); } + + [Fact] + public void ItemUsedInRetrieved() + { + var observable = new DeliveryObservableProxy(GetDeliveryClient(MockItemUsedIn)).GetItemUsedInObservable(Article.Codename); + var parents = observable.ToEnumerable().ToList(); + + Assert.NotEmpty(parents); + Assert.All(parents, item => Assert.NotNull(item.System)); + } + + [Fact] + public void AssetUsedInRetrieved() + { + var observable = new DeliveryObservableProxy(GetDeliveryClient(MockAssetUsedIn)).GetAssetUsedInObservable(ASSET_CODENAME); + var parents = observable.ToEnumerable().ToList(); + + Assert.NotEmpty(parents); + Assert.All(parents, item => Assert.NotNull(item.System)); + } + public static IOptionsMonitor CreateMonitor(DeliveryOptions options) { var mock = A.Fake>(); @@ -286,6 +308,18 @@ private void MockLanguages() .Respond("application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}languages.json"))); } + private void MockAssetUsedIn() + { + _mockHttp.When($"{_baseUrl}/assets/{ASSET_CODENAME}/used-in") + .Respond("application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}used_in.json"))); + } + + private void MockItemUsedIn() + { + _mockHttp.When($"{_baseUrl}/items/{Article.Codename}/used-in") + .Respond("application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}used_in.json"))); + } + private static void AssertArticlePropertiesNotNull(Article item) { Assert.NotNull(item.System); diff --git a/Kontent.Ai.Delivery.Rx.Tests/Fixtures/used_in.json b/Kontent.Ai.Delivery.Rx.Tests/Fixtures/used_in.json new file mode 100644 index 00000000..aabc5bcc --- /dev/null +++ b/Kontent.Ai.Delivery.Rx.Tests/Fixtures/used_in.json @@ -0,0 +1,30 @@ +{ + "items": [ + { + "system": { + "id": "35778b62-e97f-42cb-833f-8a34c14e27ce", + "name": "rich text child", + "codename": "rich_text_child", + "language": "language1", + "type": "ct", + "collection": "default", + "workflow": "default", + "workflow_step": "published", + "last_modified": "2024-08-01T14:17:50.4141347Z" + } + }, + { + "system": { + "id": "3ad63df6-a439-4a64-8193-14e999bfaaf2", + "name": "rich text child 2", + "codename": "rich_text_child_2", + "language": "language1", + "type": "ct", + "collection": "default", + "workflow": "default", + "workflow_step": "published", + "last_modified": "2024-08-01T14:17:50.4141347Z" + } + } + ] +} \ No newline at end of file diff --git a/Kontent.Ai.Delivery.Rx.Tests/Kontent.Ai.Delivery.Rx.Tests.csproj b/Kontent.Ai.Delivery.Rx.Tests/Kontent.Ai.Delivery.Rx.Tests.csproj index 52e13902..a397d5d9 100644 --- a/Kontent.Ai.Delivery.Rx.Tests/Kontent.Ai.Delivery.Rx.Tests.csproj +++ b/Kontent.Ai.Delivery.Rx.Tests/Kontent.Ai.Delivery.Rx.Tests.csproj @@ -72,4 +72,10 @@ + + + Always + + + diff --git a/Kontent.Ai.Delivery.Rx/DeliveryObservableProxy.cs b/Kontent.Ai.Delivery.Rx/DeliveryObservableProxy.cs index ddf1a04d..f6af4f5c 100644 --- a/Kontent.Ai.Delivery.Rx/DeliveryObservableProxy.cs +++ b/Kontent.Ai.Delivery.Rx/DeliveryObservableProxy.cs @@ -105,18 +105,53 @@ public IObservable GetItemsFeedObservable(params IQueryParameter[] paramet public IObservable GetItemsFeedObservable(IEnumerable parameters) where T : class { var feed = DeliveryClient?.GetItemsFeed(parameters); - return feed == null ? null : EnumerateFeed()?.ToObservable(); + return feed == null ? null : EnumerateFeed(feed)?.ToObservable(); + } - IEnumerable EnumerateFeed() - { - while (feed.HasMoreResults) - { - foreach (var contentItem in feed.FetchNextBatchAsync().Result.Items) - { - yield return contentItem; - } - } - } + /// + /// Returns an observable of strongly typed parent content items for specified content item that match the optional filtering parameters. Items are enumerated in batches. + /// + /// The codename of a content item. + /// A collection of query parameters, for example, for filtering or ordering. + /// The that represents the parent content items for the specified content item. If no query parameters are specified, parents in default language are returned. + public IObservable GetItemUsedInObservable(string codename, params IQueryParameter[] parameters) + { + return GetItemUsedInObservable(codename, (IEnumerable)parameters); + } + + /// + /// Returns an observable of strongly typed parent content items for specified content item that match the optional filtering parameters. Items are enumerated in batches. + /// + /// The codename of a content item. + /// A collection of query parameters, for example, for filtering or ordering. + /// The that represents the parent content items for the specified content item. If no query parameters are specified, parents in default language are returned. + public IObservable GetItemUsedInObservable(string codename, IEnumerable parameters) + { + var feed = DeliveryClient?.GetItemUsedIn(codename, parameters); + return feed == null ? null : EnumerateFeed(feed)?.ToObservable(); + } + + /// + /// Returns an observable of strongly typed parent content items for specified asset that match the optional filtering parameters. Items are enumerated in batches. + /// + /// The codename of an asset. + /// A collection of query parameters, for example, for filtering or ordering. + /// The that represents the parent content items for the specified content item. If no query parameters are specified, parents in default language are returned. + public IObservable GetAssetUsedInObservable(string codename, params IQueryParameter[] parameters) + { + return GetAssetUsedInObservable(codename, (IEnumerable)parameters); + } + + /// + /// Returns an observable of strongly typed parent content items for specified asset that match the optional filtering parameters. Items are enumerated in batches. + /// + /// The codename of an asset. + /// A collection of query parameters, for example, for filtering or ordering. + /// The that represents the parent content items for the specified content item. If no query parameters are specified, parents in default language are returned. + public IObservable GetAssetUsedInObservable(string codename, IEnumerable parameters) + { + var feed = DeliveryClient?.GetAssetUsedIn(codename, parameters); + return feed == null ? null : EnumerateFeed(feed)?.ToObservable(); } /// @@ -211,6 +246,16 @@ public IObservable GetLanguagesObservable(params IQueryParameter[] pa #endregion #region "Private methods" + private static IEnumerable EnumerateFeed(IDeliveryItemsFeed feed) where T : class + { + while (feed.HasMoreResults) + { + foreach (var contentItem in feed.FetchNextBatchAsync().Result.Items) + { + yield return contentItem; + } + } + } private static IObservable GetObservableOfOne(Func responseFactory) { diff --git a/Kontent.Ai.Delivery.Tests/DeliveryClientTests.cs b/Kontent.Ai.Delivery.Tests/DeliveryClientTests.cs index 2b0a4e6e..69189454 100644 --- a/Kontent.Ai.Delivery.Tests/DeliveryClientTests.cs +++ b/Kontent.Ai.Delivery.Tests/DeliveryClientTests.cs @@ -26,6 +26,7 @@ namespace Kontent.Ai.Delivery.Tests { public class DeliveryClientTests { + private const string AssetCodename = "asset_codename"; private readonly Guid _guid; private readonly string _baseUrl; private readonly MockHttpMessageHandler _mockHttp; @@ -435,6 +436,224 @@ public async Task GetItemsFeed_InvalidEnvironmentId_RespondsWithApiError() Assert.Null(actualResponse.Items); } + [Theory] + [MemberData(nameof(InvalidUsedInQueryParameters))] + public void GetItemUsedIn_InvalidUsedInQueryParameter_ThrowsArgumentException(IQueryParameter parameter) + { + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + Assert.Throws(() => client.GetItemUsedIn("item_codename", parameter)); + } + + [Fact] + public async Task GetItemUsedIn_SingleBatch_FetchNextBatchAsync() + { + _mockHttp + .When($"{_baseUrl}/items/{Article.Codename}/used-in") + .WithQueryString("system.language[in]=en-US") + .Respond("application/json", await File.ReadAllTextAsync(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}DeliveryClient{Path.DirectorySeparatorChar}used_in.json"))); + + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + var feed = client.GetItemUsedIn(Article.Codename, new InFilter("system.language", "en-US")); + var items = new List(); + var timesCalled = 0; + while (feed.HasMoreResults) + { + timesCalled++; + var response = await feed.FetchNextBatchAsync(); + items.AddRange(response.Items); + } + + Assert.Equal(6, items.Count); + Assert.Equal(1, timesCalled); + } + + [Fact] + public async Task GetItemUsedIn_SingleBatchWithContinuationToken_FetchNextBatchAsync() + { + // Single batch with specific continuation token. + _mockHttp + .When($"{_baseUrl}/items/{Article.Codename}/used-in") + .WithQueryString("system.language[in]=en-US") + .WithHeaders("X-Continuation", "token") + .Respond("application/json", await File.ReadAllTextAsync(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}DeliveryClient{Path.DirectorySeparatorChar}used_in.json"))); + + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + var feed = client.GetItemUsedIn(Article.Codename, new InFilter("system.language", "en-US")); + var items = new List(); + var timesCalled = 0; + while (feed.HasMoreResults) + { + timesCalled++; + var response = await feed.FetchNextBatchAsync("token"); + items.AddRange(response.Items); + } + + Assert.Equal(6, items.Count); + Assert.Equal(1, timesCalled); + } + + [Fact] + public async Task GetItemUsedIn_MultipleBatches_FetchNextBatchAsync() + { + // Second batch + _mockHttp + .When($"{_baseUrl}/items/{Article.Codename}/used-in") + .WithQueryString("system.language[in]=en-US") + .WithHeaders("X-Continuation", "token") + .Respond("application/json", await File.ReadAllTextAsync(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}DeliveryClient{Path.DirectorySeparatorChar}used_in_batch_2.json"))); + + // First batch + _mockHttp + .When($"{_baseUrl}/items/{Article.Codename}/used-in") + .WithQueryString("system.language[in]=en-US") + .Respond(new[] { new KeyValuePair("X-Continuation", "token"), }, "application/json", await File.ReadAllTextAsync(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}DeliveryClient{Path.DirectorySeparatorChar}used_in_batch_1.json"))); + + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + var feed = client.GetItemUsedIn(Article.Codename, new InFilter("system.language", "en-US")); + var items = new List(); + var timesCalled = 0; + while (feed.HasMoreResults) + { + timesCalled++; + var response = await feed.FetchNextBatchAsync(); + items.AddRange(response.Items); + } + + Assert.Equal(6, items.Count); + Assert.Equal(2, timesCalled); + } + + [Fact] + public async Task GetItemUsedIn_InvalidEnvironmentId_RespondsWithApiError() + { + var expectedError = CreateInvalidEnvironmentIdApiError(); + var response = CreateApiErrorResponse(expectedError); + + _mockHttp + .When($"{_baseUrl}/items/{Article.Codename}/used-in") + .Respond(HttpStatusCode.NotFound, "application/json", response); + + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + var actualResponse = await client.GetItemUsedIn(Article.Codename).FetchNextBatchAsync(); + + AssertErrorResponse(actualResponse, expectedError); + Assert.Null(actualResponse.Items); + } + + [Theory] + [MemberData(nameof(InvalidUsedInQueryParameters))] + public void GetAssetUsedIn_InvalidUsedInQueryParameter_ThrowsArgumentException(IQueryParameter parameter) + { + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + Assert.Throws(() => client.GetAssetUsedIn(AssetCodename, parameter)); + } + + [Fact] + public async Task GetAssetUsedIn_SingleBatch_FetchNextBatchAsync() + { + _mockHttp + .When($"{_baseUrl}/assets/{AssetCodename}/used-in") + .WithQueryString("system.type=article") + .Respond("application/json", await File.ReadAllTextAsync(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}DeliveryClient{Path.DirectorySeparatorChar}used_in.json"))); + + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + var feed = client.GetAssetUsedIn(AssetCodename, new SystemTypeEqualsFilter("article")); + var items = new List(); + var timesCalled = 0; + while (feed.HasMoreResults) + { + timesCalled++; + var response = await feed.FetchNextBatchAsync(); + items.AddRange(response.Items); + } + + Assert.Equal(6, items.Count); + Assert.Equal(1, timesCalled); + } + + [Fact] + public async Task GetAssetUsedIn_SingleBatchWithContinuationToken_FetchNextBatchAsync() + { + // Single batch with specific continuation token. + _mockHttp + .When($"{_baseUrl}/assets/{AssetCodename}/used-in") + .WithQueryString("system.type=article") + .WithHeaders("X-Continuation", "token") + .Respond("application/json", await File.ReadAllTextAsync(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}DeliveryClient{Path.DirectorySeparatorChar}used_in.json"))); + + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + var feed = client.GetAssetUsedIn(AssetCodename, new SystemTypeEqualsFilter("article")); + var items = new List(); + var timesCalled = 0; + while (feed.HasMoreResults) + { + timesCalled++; + var response = await feed.FetchNextBatchAsync("token"); + items.AddRange(response.Items); + } + + Assert.Equal(6, items.Count); + Assert.Equal(1, timesCalled); + } + + [Fact] + public async Task GetAssetUsedIn_MultipleBatches_FetchNextBatchAsync() + { + // Second batch + _mockHttp + .When($"{_baseUrl}/assets/{AssetCodename}/used-in") + .WithQueryString("system.type=article") + .WithHeaders("X-Continuation", "token") + .Respond("application/json", await File.ReadAllTextAsync(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}DeliveryClient{Path.DirectorySeparatorChar}used_in_batch_2.json"))); + + // First batch + _mockHttp + .When($"{_baseUrl}/assets/{AssetCodename}/used-in") + .WithQueryString("system.type=article") + .Respond(new[] { new KeyValuePair("X-Continuation", "token"), }, "application/json", await File.ReadAllTextAsync(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}DeliveryClient{Path.DirectorySeparatorChar}used_in_batch_1.json"))); + + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + var feed = client.GetAssetUsedIn(AssetCodename, new SystemTypeEqualsFilter("article")); + var items = new List(); + var timesCalled = 0; + while (feed.HasMoreResults) + { + timesCalled++; + var response = await feed.FetchNextBatchAsync(); + items.AddRange(response.Items); + } + + Assert.Equal(6, items.Count); + Assert.Equal(2, timesCalled); + } + + [Fact] + public async Task GetAssetUsedIn_InvalidEnvironmentId_RespondsWithApiError() + { + var expectedError = CreateInvalidEnvironmentIdApiError(); + var response = CreateApiErrorResponse(expectedError); + + _mockHttp + .When($"{_baseUrl}/assets/{AssetCodename}/used-in") + .Respond(HttpStatusCode.NotFound, "application/json", response); + + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + var actualResponse = await client.GetAssetUsedIn(AssetCodename).FetchNextBatchAsync(); + + AssertErrorResponse(actualResponse, expectedError); + Assert.Null(actualResponse.Items); + } + [Fact] public async Task GetTypeAsync() { @@ -1918,6 +2137,46 @@ public async Task SyncApi_GetSyncAsync_GetSyncItems_WithoutTypeProvider_ReturnsG } } + public static IEnumerable InvalidUsedInQueryParameters => + [ + [new DepthParameter(2)], + [new LimitParameter(2)], + [new SkipParameter(2)], + [new OrderParameter("test")], + [new LanguageParameter("test")], + [new IncludeTotalCountParameter()], + [new ExcludeElementsParameter("test")], + [new ElementsParameter("test")], + [new AllFilter("elements.test", "test")], + [new AnyFilter("elements.test2", "test")], + [new ContainsFilter("elements.te2st", "test")], + [new EmptyFilter("elements.test")], + [new EqualsFilter("elements.te2st", "test")], + [new GreaterThanFilter("elements.test", "test")], + [new GreaterThanOrEqualFilter("elements.tes2t", "test")], + [new InFilter("elements.test", "test")], + [new LessThanFilter("elements.tes2t", "test")], + [new LessThanOrEqualFilter("elements.test", "test")], + [new NotEmptyFilter("elements.test")], + [new NotEqualsFilter("elements.t2est", "test")], + [new NotInFilter("elements.test", "test")], + [new RangeFilter("elements.tes2t", "test", "test2")], + [new AllFilter("etest", "test")], + [new AnyFilter("test2", "test")], + [new ContainsFilter("te2st", "test")], + [new EmptyFilter("eletest")], + [new EqualsFilter("elemente2st", "test")], + [new GreaterThanFilter("etest", "test")], + [new GreaterThanOrEqualFilter("elementstes2t", "test")], + [new InFilter("elementstest", "test")], + [new LessThanFilter("elementstes2t", "test")], + [new LessThanOrEqualFilter("elementtest", "test")], + [new NotEmptyFilter("elementtest")], + [new NotEqualsFilter("elementt2est", "test")], + [new NotInFilter("elementstest", "test")], + [new RangeFilter("elementstes2t", "test", "test2")], + ]; + private void AssertSystemPropertiesEquality(JObject expectedSystemValues, IContentItemSystemAttributes system) { Assert.Equal(expectedSystemValues["codename"].ToString(), system.Codename.ToString()); diff --git a/Kontent.Ai.Delivery.Tests/Fixtures/DeliveryClient/used_in.json b/Kontent.Ai.Delivery.Tests/Fixtures/DeliveryClient/used_in.json new file mode 100644 index 00000000..161172e6 --- /dev/null +++ b/Kontent.Ai.Delivery.Tests/Fixtures/DeliveryClient/used_in.json @@ -0,0 +1,82 @@ +{ + "items": [ + { + "system": { + "id": "cf106f4e-30a4-42ef-b313-b8ea3fd3e5c5", + "name": "Coffee Beverages Explained", + "codename": "coffee_beverages_explained", + "language": "en-US", + "type": "article", + "collection": "default", + "last_modified": "2019-03-27T13:12:58.578Z", + "workflow": "default", + "workflow_step": "published" + } + }, + { + "system": { + "id": "117cdfae-52cf-4885-b271-66aef6825612", + "name": "Coffee processing techniques", + "codename": "coffee_processing_techniques", + "language": "en-US", + "type": "article", + "collection": "default", + "last_modified": "2019-03-27T13:13:35.312Z", + "workflow": "default", + "workflow_step": "published" + } + }, + { + "system": { + "id": "23f71096-fa89-4f59-a3f9-970e970944ec", + "name": "Donate with us", + "codename": "donate_with_us", + "language": "en-US", + "type": "article", + "collection": "default", + "last_modified": "2019-03-27T13:14:07.384Z", + "workflow": "default", + "workflow_step": "published" + } + }, + { + "system": { + "id": "f4b3fc05-e988-4dae-9ac1-a94aba566474", + "name": "On Roasts", + "codename": "on_roasts", + "language": "en-US", + "type": "article", + "collection": "default", + "last_modified": "2019-03-27T13:21:11.38Z", + "workflow": "default", + "workflow_step": "published" + } + }, + { + "system": { + "id": "b2fea94c-73fd-42ec-a22f-f409878de187", + "name": "Origins of Arabica Bourbon", + "codename": "origins_of_arabica_bourbon", + "language": "en-US", + "type": "article", + "collection": "default", + "last_modified": "2019-03-27T13:21:49.151Z", + "workflow": "default", + "workflow_step": "published" + } + }, + { + "system": { + "id": "3120ec15-a4a2-47ec-8ccd-c85ac8ac5ba5", + "name": "Which brewing fits you?", + "codename": "which_brewing_fits_you_", + "language": "en-US", + "type": "article", + "collection": "default", + "last_modified": "2019-03-27T13:24:54.042Z", + "workflow": "default", + "workflow_step": "published" + } + } + ] +} \ No newline at end of file diff --git a/Kontent.Ai.Delivery.Tests/Fixtures/DeliveryClient/used_in_batch_1.json b/Kontent.Ai.Delivery.Tests/Fixtures/DeliveryClient/used_in_batch_1.json new file mode 100644 index 00000000..3b5f0202 --- /dev/null +++ b/Kontent.Ai.Delivery.Tests/Fixtures/DeliveryClient/used_in_batch_1.json @@ -0,0 +1,69 @@ +{ + "items": [ + { + "system": { + "id": "cf106f4e-30a4-42ef-b313-b8ea3fd3e5c5", + "name": "Coffee Beverages Explained", + "codename": "coffee_beverages_explained", + "language": "en-US", + "type": "article", + "collection": "default", + "last_modified": "2019-03-27T13:12:58.578Z", + "workflow": "default", + "workflow_step": "published" + } + }, + { + "system": { + "id": "117cdfae-52cf-4885-b271-66aef6825612", + "name": "Coffee processing techniques", + "codename": "coffee_processing_techniques", + "language": "en-US", + "type": "article", + "collection": "default", + "last_modified": "2019-03-27T13:13:35.312Z", + "workflow": "default", + "workflow_step": "published" + } + }, + { + "system": { + "id": "23f71096-fa89-4f59-a3f9-970e970944ec", + "name": "Donate with us", + "codename": "donate_with_us", + "language": "en-US", + "type": "article", + "collection": "default", + "last_modified": "2019-03-27T13:14:07.384Z", + "workflow": "default", + "workflow_step": "published" + } + }, + { + "system": { + "id": "f4b3fc05-e988-4dae-9ac1-a94aba566474", + "name": "On Roasts", + "codename": "on_roasts", + "language": "en-US", + "type": "article", + "collection": "default", + "last_modified": "2019-03-27T13:21:11.38Z", + "workflow": "default", + "workflow_step": "published" + } + }, + { + "system": { + "id": "b2fea94c-73fd-42ec-a22f-f409878de187", + "name": "Origins of Arabica Bourbon", + "codename": "origins_of_arabica_bourbon", + "language": "en-US", + "type": "article", + "collection": "default", + "last_modified": "2019-03-27T13:21:49.151Z", + "workflow": "default", + "workflow_step": "published" + } + } + ] +} \ No newline at end of file diff --git a/Kontent.Ai.Delivery.Tests/Fixtures/DeliveryClient/used_in_batch_2.json b/Kontent.Ai.Delivery.Tests/Fixtures/DeliveryClient/used_in_batch_2.json new file mode 100644 index 00000000..29554e0a --- /dev/null +++ b/Kontent.Ai.Delivery.Tests/Fixtures/DeliveryClient/used_in_batch_2.json @@ -0,0 +1,17 @@ +{ + "items": [ + { + "system": { + "id": "3120ec15-a4a2-47ec-8ccd-c85ac8ac5ba5", + "name": "Which brewing fits you?", + "codename": "which_brewing_fits_you_", + "language": "en-US", + "type": "article", + "collection": "default", + "last_modified": "2019-03-27T13:24:54.042Z", + "workflow": "default", + "workflow_step": "published" + } + } + ] +} \ No newline at end of file diff --git a/Kontent.Ai.Delivery.Tests/Kontent.Ai.Delivery.Tests.csproj b/Kontent.Ai.Delivery.Tests/Kontent.Ai.Delivery.Tests.csproj index 5f1b7aa7..c5954aef 100644 --- a/Kontent.Ai.Delivery.Tests/Kontent.Ai.Delivery.Tests.csproj +++ b/Kontent.Ai.Delivery.Tests/Kontent.Ai.Delivery.Tests.csproj @@ -87,6 +87,15 @@ Always + + Always + + + Always + + + Always + Always diff --git a/Kontent.Ai.Delivery/DeliveryClient.cs b/Kontent.Ai.Delivery/DeliveryClient.cs index 4c812c4e..19f6cdec 100644 --- a/Kontent.Ai.Delivery/DeliveryClient.cs +++ b/Kontent.Ai.Delivery/DeliveryClient.cs @@ -13,6 +13,7 @@ using Kontent.Ai.Delivery.SharedModels; using Kontent.Ai.Delivery.Sync; using Kontent.Ai.Delivery.TaxonomyGroups; +using Kontent.Ai.Delivery.UsedIn; using Kontent.Ai.Urls.Delivery; using Kontent.Ai.Urls.Delivery.QueryParameters; using Kontent.Ai.Urls.Delivery.QueryParameters.Filters; @@ -363,6 +364,48 @@ public async Task GetSyncAsync(string continuationToken) return new DeliverySyncResponse(response, itemModels); } + /// + /// Returns a feed that is used to traverse through strongly typed parent content items matching the optional filtering parameters. + /// + /// The codename of a content item. + /// A collection of query parameters, for example, for filtering or ordering. + /// The instance that can be used to enumerate through content item parents for the specified item codename. If no query parameters are specified, default language parents are enumerated. + public IDeliveryItemsFeed GetItemUsedIn(string codename, IEnumerable parameters = null) + { + ValidateUsedInParameters(parameters); + var endpointUrl = UrlBuilder.GetItemUsedInUrl(codename, parameters); + + return new DeliveryUsedInItems(continuationToken => GetUsedInBatchAsync(endpointUrl, continuationToken)); + } + + /// + /// Returns a feed that is used to traverse through strongly typed parent content items matching the optional filtering parameters. + /// + /// The codename of an asset. + /// A collection of query parameters, for example, for filtering or ordering. + /// The instance that can be used to enumerate through asset parents for the specified asset codename. If no query parameters are specified, default language parents are enumerated. + public IDeliveryItemsFeed GetAssetUsedIn(string codename, IEnumerable parameters = null) + { + ValidateUsedInParameters(parameters); + var endpointUrl = UrlBuilder.GetAssetUsedInUrl(codename, parameters); + + return new DeliveryUsedInItems(continuationToken => GetUsedInBatchAsync(endpointUrl, continuationToken)); + } + + private async Task GetUsedInBatchAsync(string endpointUrl, string continuationToken) + { + var response = await GetDeliveryResponseAsync(endpointUrl, HttpMethod.Get, continuationToken); + + if (!response.IsSuccess) + { + return new DeliveryUsedInResponse(response); + } + + var content = await response.GetJsonContentAsync(); + var items = content["items"].ToObject>(Serializer); + + return new DeliveryUsedInResponse(response, items.Cast().ToList()); + } private async Task GetDeliveryResponseAsync(string endpointUrl, HttpMethod httpMethod, string continuationToken = null) { @@ -498,5 +541,16 @@ private static void ValidateItemsFeedParameters(IEnumerable par throw new ArgumentException("Skip parameter is not supported in items feed."); } } + + private static void ValidateUsedInParameters(IEnumerable parameters) + { + if (parameters?.Any(IsNotSystemFilter) ?? false) + { + throw new ArgumentException("Only filtering by system properties is supported in used-in endpoints."); + } + } + + private static bool IsNotSystemFilter(IQueryParameter parameter) => + !(parameter is Filter filter && filter.ElementOrAttributePath.StartsWith("system.")); } } \ No newline at end of file diff --git a/Kontent.Ai.Delivery/UsedIn/DeliveryUsedInItems.cs b/Kontent.Ai.Delivery/UsedIn/DeliveryUsedInItems.cs new file mode 100644 index 00000000..536939c7 --- /dev/null +++ b/Kontent.Ai.Delivery/UsedIn/DeliveryUsedInItems.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading.Tasks; +using Kontent.Ai.Delivery.Abstractions; + +namespace Kontent.Ai.Delivery.UsedIn +{ + /// + /// Represents a feed that can be used to retrieve strongly typed parent content items from Kontent.ai Delivery Used In API methods in smaller batches. + /// + internal class DeliveryUsedInItems : IDeliveryItemsFeed + { + internal delegate Task GetFeedResponse(string continuationToken); + + private string _continuationToken; + private readonly GetFeedResponse _getFeedResponseAsync; + + /// + /// Indicates whether there are more batches to fetch. + /// + public bool HasMoreResults { get; private set; } = true; + + /// + /// Initializes a new instance of class. + /// + /// Function to retrieve next batch of content items. + public DeliveryUsedInItems(GetFeedResponse getFeedResponseAsync) + { + _getFeedResponseAsync = getFeedResponseAsync; + } + + /// + /// Retrieves the next feed batch if available. + /// + /// Optional explicit continuation token that allows you to get the next batch from a specific point in the feed. + /// Instance of class that contains a list of strongly typed content items. + public async Task> FetchNextBatchAsync(string continuationToken = null) + { + if (!HasMoreResults) + { + throw new InvalidOperationException("The feed has already been enumerated and there are no more results."); + } + + var response = await _getFeedResponseAsync(continuationToken ?? _continuationToken); + + if (!response.ApiResponse.IsSuccess) + { + return new DeliveryUsedInResponse(response.ApiResponse, null); + } + + _continuationToken = response.ApiResponse.ContinuationToken; + HasMoreResults = !string.IsNullOrEmpty(response.ApiResponse.ContinuationToken); + + return response; + } + } +} \ No newline at end of file diff --git a/Kontent.Ai.Delivery/UsedIn/DeliveryUsedInResponse.cs b/Kontent.Ai.Delivery/UsedIn/DeliveryUsedInResponse.cs new file mode 100644 index 00000000..2a167653 --- /dev/null +++ b/Kontent.Ai.Delivery/UsedIn/DeliveryUsedInResponse.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using Kontent.Ai.Delivery.Abstractions; +using Newtonsoft.Json; +using Kontent.Ai.Delivery.SharedModels; + +namespace Kontent.Ai.Delivery.UsedIn; + +internal sealed class DeliveryUsedInResponse : AbstractResponse, IDeliveryItemsFeedResponse +{ + /// + public IList Items { get; } + + /// + /// Initializes a new instance of the + /// + /// + /// + [JsonConstructor] + internal DeliveryUsedInResponse(IApiResponse response, IList items) + : base(response) + { + Items = items; + } + + internal DeliveryUsedInResponse(IApiResponse response) + : base(response) + { + } +} \ No newline at end of file diff --git a/Kontent.Ai.Delivery/UsedIn/UsedInItem.cs b/Kontent.Ai.Delivery/UsedIn/UsedInItem.cs new file mode 100644 index 00000000..4f4f68aa --- /dev/null +++ b/Kontent.Ai.Delivery/UsedIn/UsedInItem.cs @@ -0,0 +1,21 @@ +using Kontent.Ai.Delivery.Abstractions; +using Newtonsoft.Json; + +namespace Kontent.Ai.Delivery.UsedIn; + +/// +internal sealed class UsedInItem : IUsedInItem +{ + /// + [JsonProperty("system")] + public IUsedInItemSystemAttributes System { get; internal set; } + + /// + /// Constructor used for deserialization (e.g. for caching purposes), contains no logic. + /// + [JsonConstructor] + public UsedInItem(IUsedInItemSystemAttributes system) + { + System = system; + } +} \ No newline at end of file diff --git a/Kontent.Ai.Delivery/UsedIn/UsedInItemSystemAttributes.cs b/Kontent.Ai.Delivery/UsedIn/UsedInItemSystemAttributes.cs new file mode 100644 index 00000000..1a78965c --- /dev/null +++ b/Kontent.Ai.Delivery/UsedIn/UsedInItemSystemAttributes.cs @@ -0,0 +1,56 @@ +using System; +using System.Diagnostics; +using Kontent.Ai.Delivery.Abstractions; +using Newtonsoft.Json; + +namespace Kontent.Ai.Delivery.UsedIn +{ + /// + [DebuggerDisplay("Id = {" + nameof(Id) + "}")] + internal sealed class UsedInItemSystemAttributes : IUsedInItemSystemAttributes + { + /// + [JsonProperty("id")] + public string Id { get; internal set; } + + /// + [JsonProperty("name")] + public string Name { get; internal set; } + + /// + [JsonProperty("codename")] + public string Codename { get; internal set; } + + /// + [JsonProperty("type")] + public string Type { get; internal set; } + + /// + [JsonProperty("last_modified")] + public DateTime LastModified { get; internal set; } + + /// + [JsonProperty("language")] + public string Language { get; internal set; } + + /// + [JsonProperty("collection")] + public string Collection { get; internal set; } + + /// + [JsonProperty("workflow")] + public string Workflow { get; internal set; } + + /// + [JsonProperty("workflow_step")] + public string WorkflowStep { get; internal set; } + + /// + /// Initializes a new instance of the class. + /// + [JsonConstructor] + public UsedInItemSystemAttributes() + { + } + } +} \ No newline at end of file diff --git a/Kontent.Ai.Urls.Tests/DeliveryEndpointUrlBuilderTests.cs b/Kontent.Ai.Urls.Tests/DeliveryEndpointUrlBuilderTests.cs index a148b1b3..ef37a723 100644 --- a/Kontent.Ai.Urls.Tests/DeliveryEndpointUrlBuilderTests.cs +++ b/Kontent.Ai.Urls.Tests/DeliveryEndpointUrlBuilderTests.cs @@ -147,4 +147,30 @@ public void GetLanguagesUrl_ReturnsLanguagesUrl() var expectedLanguagesUrl = $"https://deliver.kontent.ai:443/{options.EnvironmentId}/languages"; Assert.Equal(expectedLanguagesUrl, actualLanguagesUrl); } + + [Fact] + public void GetItemUsedInUrl_ReturnsItemUsedInUrl() + { + var options = new DeliveryOptions() { EnvironmentId = Guid.NewGuid().ToString() }; + var optionsMonitor = new FakeOptionsMonitor(options); + var deliveryEndpointUrlBuilder = new DeliveryEndpointUrlBuilder(optionsMonitor); + + var actualItemUsedInUrl = deliveryEndpointUrlBuilder.GetItemUsedInUrl("item_codename", new IQueryParameter[] { }); + + var expectedItemUsedInUrl = $"https://deliver.kontent.ai:443/{options.EnvironmentId}/items/item_codename/used-in"; + Assert.Equal(expectedItemUsedInUrl, actualItemUsedInUrl); + } + + [Fact] + public void GetAssetUsedInUrl_ReturnsAssetUsedInUrl() + { + var options = new DeliveryOptions() { EnvironmentId = Guid.NewGuid().ToString() }; + var optionsMonitor = new FakeOptionsMonitor(options); + var deliveryEndpointUrlBuilder = new DeliveryEndpointUrlBuilder(optionsMonitor); + + var actualAssetUsedInUrl = deliveryEndpointUrlBuilder.GetAssetUsedInUrl("asset_codename", new IQueryParameter[] { }); + + var expectedAssetUsedInUrl = $"https://deliver.kontent.ai:443/{options.EnvironmentId}/assets/asset_codename/used-in"; + Assert.Equal(expectedAssetUsedInUrl, actualAssetUsedInUrl); + } } \ No newline at end of file diff --git a/Kontent.Ai.Urls/Delivery/DeliveryEndpointUrlBuilder.cs b/Kontent.Ai.Urls/Delivery/DeliveryEndpointUrlBuilder.cs index 6c3c0195..cb698cf4 100644 --- a/Kontent.Ai.Urls/Delivery/DeliveryEndpointUrlBuilder.cs +++ b/Kontent.Ai.Urls/Delivery/DeliveryEndpointUrlBuilder.cs @@ -14,6 +14,8 @@ public class DeliveryEndpointUrlBuilder { private const int UrlMaxLength = 65519; private const string UrlTemplateItem = "/items/{0}"; + private const string UrlTemplateItemUsedIn = "/items/{0}/used-in"; + private const string UrlTemplateAssetUsedIn= "/assets/{0}/used-in"; private const string UrlTemplateItems = "/items"; private const string UrlTemplateItemsFeed = "/items-feed"; private const string UrlTemplateType = "/types/{0}"; @@ -52,7 +54,7 @@ public DeliveryEndpointUrlBuilder(IOptionsMonitor deliveryOptio /// /// Generates an URL for retrieving a single content item. /// - /// ID of the item to be retrieved. + /// Codename of the item to be retrieved. /// Additional filtering parameters. /// A valid URL containing correctly formatted parameters. public string GetItemUrl(string codename, IEnumerable parameters) @@ -84,7 +86,7 @@ public string GetItemsFeedUrl(IEnumerable parameters) /// /// Generates an URL for retrieving a single content type. /// - /// ID of the content type to be retrieved. + /// Codename of the content type to be retrieved. /// Additional filtering parameters. /// A valid URL containing correctly formatted parameters. public string GetTypeUrl(string codename, IEnumerable parameters = null) @@ -116,7 +118,7 @@ public string GetContentElementUrl(string contentTypeCodename, string contentEle /// /// Generates an URL for retrieving a single taxonomy. /// - /// ID of the content type to be retrieved. + /// Codename of the taxonomy to be retrieved. /// A valid URL containing correctly formatted parameters. public string GetTaxonomyUrl(string codename) { @@ -163,6 +165,28 @@ public string GetSyncUrl() return GetUrl(UrlTemplateSync); } + /// + /// Generates an URL for retrieving parents for a single content item. + /// + /// Codename of the content item to be retrieved. + /// Additional filtering parameters. + /// A valid URL containing correctly formatted parameters. + public string GetItemUsedInUrl(string codename, IEnumerable parameters) + { + return GetUrl(string.Format(UrlTemplateItemUsedIn, Uri.EscapeDataString(codename)), parameters); + } + + /// + /// Generates an URL for retrieving parents for a single asset. + /// + /// Codename of the asset to be retrieved. + /// Additional filtering parameters. + /// A valid URL containing correctly formatted parameters. + public string GetAssetUsedInUrl(string codename, IEnumerable parameters) + { + return GetUrl(string.Format(UrlTemplateAssetUsedIn, Uri.EscapeDataString(codename)), parameters); + } + private string GetUrl(string path, IEnumerable parameters) { if (parameters != null) From 502d4e23ce34f94d91699712024fab2a01dde065 Mon Sep 17 00:00:00 2001 From: tomasw_kontent Date: Fri, 28 Feb 2025 14:47:46 +0100 Subject: [PATCH 2/3] fix used in methods documentation --- Kontent.Ai.Delivery.Abstractions/DeliveryClientExtensions.cs | 4 ++-- Kontent.Ai.Delivery.Abstractions/IDeliveryClient.cs | 4 ++-- Kontent.Ai.Delivery.Caching/DeliveryClientCache.cs | 4 ++-- Kontent.Ai.Delivery/DeliveryClient.cs | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Kontent.Ai.Delivery.Abstractions/DeliveryClientExtensions.cs b/Kontent.Ai.Delivery.Abstractions/DeliveryClientExtensions.cs index 3adc6caf..908ca06d 100644 --- a/Kontent.Ai.Delivery.Abstractions/DeliveryClientExtensions.cs +++ b/Kontent.Ai.Delivery.Abstractions/DeliveryClientExtensions.cs @@ -94,7 +94,7 @@ public static Task PostSyncInitAsync(this IDeliveryCl /// /// An instance of the /// The codename of a content item. - /// An array that contains zero or more query parameters, for example, for filtering or ordering. + /// An array that contains zero or more query parameters for filtering. /// The instance that can be used to enumerate through content item parents for the specified item codename. If no query parameters are specified, default language parents are enumerated. public static IDeliveryItemsFeed GetItemUsedIn(this IDeliveryClient client, string codename, params IQueryParameter[] parameters) { @@ -106,7 +106,7 @@ public static IDeliveryItemsFeed GetItemUsedIn(this IDeliveryClient /// /// An instance of the /// The codename of an asset. - /// An array that contains zero or more query parameters, for example, for filtering, or ordering. + /// An array that contains zero or more query parameters for filtering. /// The instance that can be used to enumerate through asset parents for the specified asset codename. If no query parameters are specified, default language parents are enumerated. public static IDeliveryItemsFeed GetAssetUsedIn(this IDeliveryClient client, string codename, params IQueryParameter[] parameters) { diff --git a/Kontent.Ai.Delivery.Abstractions/IDeliveryClient.cs b/Kontent.Ai.Delivery.Abstractions/IDeliveryClient.cs index 6b76cfd0..f644d704 100644 --- a/Kontent.Ai.Delivery.Abstractions/IDeliveryClient.cs +++ b/Kontent.Ai.Delivery.Abstractions/IDeliveryClient.cs @@ -94,7 +94,7 @@ public interface IDeliveryClient /// Returns a feed that is used to traverse through strongly typed parent content items matching the optional filtering parameters. /// /// The codename of a content item. - /// A collection of query parameters, for example, for filtering or ordering. + /// A collection of query parameters for filtering. /// The instance that can be used to enumerate through content item parents for the specified item codename. If no query parameters are specified, default language parents are enumerated. public IDeliveryItemsFeed GetItemUsedIn(string codename, IEnumerable parameters = null); @@ -102,7 +102,7 @@ public interface IDeliveryClient /// Returns a feed that is used to traverse through strongly typed parent content items matching the optional filtering parameters. /// /// The codename of an asset. - /// A collection of query parameters, for example, for filtering or ordering. + /// A collection of query parameters for filtering. /// The instance that can be used to enumerate through asset parents for the specified asset codename. If no query parameters are specified, default language parents are enumerated. public IDeliveryItemsFeed GetAssetUsedIn(string codename, IEnumerable parameters = null); } diff --git a/Kontent.Ai.Delivery.Caching/DeliveryClientCache.cs b/Kontent.Ai.Delivery.Caching/DeliveryClientCache.cs index 3ea988ad..3310193f 100644 --- a/Kontent.Ai.Delivery.Caching/DeliveryClientCache.cs +++ b/Kontent.Ai.Delivery.Caching/DeliveryClientCache.cs @@ -180,7 +180,7 @@ public Task GetSyncAsync(string continuationToken) /// Returns a feed that is used to traverse through strongly typed parent content items matching the optional filtering parameters. /// /// The codename of a content item. - /// A collection of query parameters, for example, for filtering or ordering. + /// A collection of query parameters for filtering. /// The instance that can be used to enumerate through content item parents for the specified item codename. If no query parameters are specified, default language parents are enumerated. public IDeliveryItemsFeed GetItemUsedIn(string codename, IEnumerable parameters = null) { @@ -191,7 +191,7 @@ public IDeliveryItemsFeed GetItemUsedIn(string codename, IEnumerabl /// Returns a feed that is used to traverse through strongly typed parent content items matching the optional filtering parameters. /// /// The codename of an asset. - /// A collection of query parameters, for example, for filtering or ordering. + /// A collection of query parameters for filtering. /// The instance that can be used to enumerate through asset parents for the specified asset codename. If no query parameters are specified, default language parents are enumerated. public IDeliveryItemsFeed GetAssetUsedIn(string codename, IEnumerable parameters = null) { diff --git a/Kontent.Ai.Delivery/DeliveryClient.cs b/Kontent.Ai.Delivery/DeliveryClient.cs index 19f6cdec..c7b2a637 100644 --- a/Kontent.Ai.Delivery/DeliveryClient.cs +++ b/Kontent.Ai.Delivery/DeliveryClient.cs @@ -368,7 +368,7 @@ public async Task GetSyncAsync(string continuationToken) /// Returns a feed that is used to traverse through strongly typed parent content items matching the optional filtering parameters. /// /// The codename of a content item. - /// A collection of query parameters, for example, for filtering or ordering. + /// A collection of query parameters for filtering. /// The instance that can be used to enumerate through content item parents for the specified item codename. If no query parameters are specified, default language parents are enumerated. public IDeliveryItemsFeed GetItemUsedIn(string codename, IEnumerable parameters = null) { @@ -382,7 +382,7 @@ public IDeliveryItemsFeed GetItemUsedIn(string codename, IEnumerabl /// Returns a feed that is used to traverse through strongly typed parent content items matching the optional filtering parameters. /// /// The codename of an asset. - /// A collection of query parameters, for example, for filtering or ordering. + /// A collection of query parameters for filtering. /// The instance that can be used to enumerate through asset parents for the specified asset codename. If no query parameters are specified, default language parents are enumerated. public IDeliveryItemsFeed GetAssetUsedIn(string codename, IEnumerable parameters = null) { From 5ad0c35dba61b63d660bd59d6b08254c4031e3c8 Mon Sep 17 00:00:00 2001 From: tomasw_kontent Date: Sun, 9 Mar 2025 21:27:37 +0100 Subject: [PATCH 3/3] Make DeliveryObservableProxy.EnumerateFeed async --- .../Kontent.Ai.Delivery.Rx.Tests.csproj | 1 + .../DeliveryItemsFeedExtensions.cs | 59 +++++++++++++++++++ .../DeliveryObservableProxy.cs | 22 ++----- .../Kontent.Ai.Delivery.Rx.csproj | 6 +- Kontent.Ai.Delivery/DeliveryClient.cs | 10 ++++ 5 files changed, 78 insertions(+), 20 deletions(-) create mode 100644 Kontent.Ai.Delivery.Rx/DeliveryItemsFeedExtensions.cs diff --git a/Kontent.Ai.Delivery.Rx.Tests/Kontent.Ai.Delivery.Rx.Tests.csproj b/Kontent.Ai.Delivery.Rx.Tests/Kontent.Ai.Delivery.Rx.Tests.csproj index a397d5d9..9ec7f9ba 100644 --- a/Kontent.Ai.Delivery.Rx.Tests/Kontent.Ai.Delivery.Rx.Tests.csproj +++ b/Kontent.Ai.Delivery.Rx.Tests/Kontent.Ai.Delivery.Rx.Tests.csproj @@ -47,6 +47,7 @@ + all diff --git a/Kontent.Ai.Delivery.Rx/DeliveryItemsFeedExtensions.cs b/Kontent.Ai.Delivery.Rx/DeliveryItemsFeedExtensions.cs new file mode 100644 index 00000000..7851dec2 --- /dev/null +++ b/Kontent.Ai.Delivery.Rx/DeliveryItemsFeedExtensions.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; +using System.Reactive.Linq; +using Kontent.Ai.Delivery.Abstractions; + +namespace Kontent.Ai.Delivery.Rx; + +/// +/// Provides extension methods for working with instances in a reactive programming context. +/// +public static class DeliveryItemsFeedExtensions +{ + /// + /// Converts an into an sequence. + /// + /// The type of content items in the feed. + /// The instance to convert. + /// + /// An sequence that emits items retrieved from the feed. + /// If the feed is null, an empty observable sequence is returned. + /// + /// + /// Propagates any exceptions that occur during the retrieval of items from the feed. + /// + public static IObservable ToObservable(this IDeliveryItemsFeed feed) where T : class + { + if (feed == null) + { + return Observable.Empty(); + } + return Observable.Create(async observer => + { + try + { + await foreach (var item in EnumerateFeed(feed)) + { + observer.OnNext(item); + } + observer.OnCompleted(); + } + catch (Exception ex) + { + observer.OnError(ex); + } + }); + } + + private static async IAsyncEnumerable EnumerateFeed(IDeliveryItemsFeed feed) where T : class + { + while (feed.HasMoreResults) + { + var batch = await feed.FetchNextBatchAsync(); + foreach (var contentItem in batch.Items) + { + yield return contentItem; + } + } + } +} \ No newline at end of file diff --git a/Kontent.Ai.Delivery.Rx/DeliveryObservableProxy.cs b/Kontent.Ai.Delivery.Rx/DeliveryObservableProxy.cs index f6af4f5c..5fea0019 100644 --- a/Kontent.Ai.Delivery.Rx/DeliveryObservableProxy.cs +++ b/Kontent.Ai.Delivery.Rx/DeliveryObservableProxy.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Reactive.Disposables; using System.Reactive.Linq; +using System.Reactive; +using System.Threading.Tasks; using Kontent.Ai.Delivery.Abstractions; namespace Kontent.Ai.Delivery.Rx @@ -104,8 +106,7 @@ public IObservable GetItemsFeedObservable(params IQueryParameter[] paramet /// The that represents the content items. If no query parameters are specified, all content items are returned. public IObservable GetItemsFeedObservable(IEnumerable parameters) where T : class { - var feed = DeliveryClient?.GetItemsFeed(parameters); - return feed == null ? null : EnumerateFeed(feed)?.ToObservable(); + return DeliveryClient?.GetItemsFeed(parameters).ToObservable(); } /// @@ -127,8 +128,7 @@ public IObservable GetItemUsedInObservable(string codename, params /// The that represents the parent content items for the specified content item. If no query parameters are specified, parents in default language are returned. public IObservable GetItemUsedInObservable(string codename, IEnumerable parameters) { - var feed = DeliveryClient?.GetItemUsedIn(codename, parameters); - return feed == null ? null : EnumerateFeed(feed)?.ToObservable(); + return DeliveryClient?.GetItemUsedIn(codename, parameters).ToObservable(); } /// @@ -150,8 +150,7 @@ public IObservable GetAssetUsedInObservable(string codename, params /// The that represents the parent content items for the specified content item. If no query parameters are specified, parents in default language are returned. public IObservable GetAssetUsedInObservable(string codename, IEnumerable parameters) { - var feed = DeliveryClient?.GetAssetUsedIn(codename, parameters); - return feed == null ? null : EnumerateFeed(feed)?.ToObservable(); + return DeliveryClient?.GetAssetUsedIn(codename, parameters).ToObservable(); } /// @@ -246,17 +245,6 @@ public IObservable GetLanguagesObservable(params IQueryParameter[] pa #endregion #region "Private methods" - private static IEnumerable EnumerateFeed(IDeliveryItemsFeed feed) where T : class - { - while (feed.HasMoreResults) - { - foreach (var contentItem in feed.FetchNextBatchAsync().Result.Items) - { - yield return contentItem; - } - } - } - private static IObservable GetObservableOfOne(Func responseFactory) { return Observable.Create((IObserver observer) => diff --git a/Kontent.Ai.Delivery.Rx/Kontent.Ai.Delivery.Rx.csproj b/Kontent.Ai.Delivery.Rx/Kontent.Ai.Delivery.Rx.csproj index b532e311..85f19b68 100644 --- a/Kontent.Ai.Delivery.Rx/Kontent.Ai.Delivery.Rx.csproj +++ b/Kontent.Ai.Delivery.Rx/Kontent.Ai.Delivery.Rx.csproj @@ -25,10 +25,10 @@ - + - - + + diff --git a/Kontent.Ai.Delivery/DeliveryClient.cs b/Kontent.Ai.Delivery/DeliveryClient.cs index c7b2a637..c89fc577 100644 --- a/Kontent.Ai.Delivery/DeliveryClient.cs +++ b/Kontent.Ai.Delivery/DeliveryClient.cs @@ -372,6 +372,11 @@ public async Task GetSyncAsync(string continuationToken) /// The instance that can be used to enumerate through content item parents for the specified item codename. If no query parameters are specified, default language parents are enumerated. public IDeliveryItemsFeed GetItemUsedIn(string codename, IEnumerable parameters = null) { + if (codename == null) + { + throw new ArgumentNullException(nameof(codename), "The codename of a content item is not specified."); + } + ValidateUsedInParameters(parameters); var endpointUrl = UrlBuilder.GetItemUsedInUrl(codename, parameters); @@ -386,6 +391,11 @@ public IDeliveryItemsFeed GetItemUsedIn(string codename, IEnumerabl /// The instance that can be used to enumerate through asset parents for the specified asset codename. If no query parameters are specified, default language parents are enumerated. public IDeliveryItemsFeed GetAssetUsedIn(string codename, IEnumerable parameters = null) { + if (codename == null) + { + throw new ArgumentNullException(nameof(codename), "The codename of an asset is not specified."); + } + ValidateUsedInParameters(parameters); var endpointUrl = UrlBuilder.GetAssetUsedInUrl(codename, parameters);