diff --git a/Kontent.Ai.Delivery.Abstractions/DeliveryClientExtensions.cs b/Kontent.Ai.Delivery.Abstractions/DeliveryClientExtensions.cs index 9a97af80..908ca06d 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 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) + { + 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 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) + { + return client.GetAssetUsedIn(codename, parameters); + } } } diff --git a/Kontent.Ai.Delivery.Abstractions/IDeliveryClient.cs b/Kontent.Ai.Delivery.Abstractions/IDeliveryClient.cs index 5abbf94a..f644d704 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 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); + + /// + /// 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 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); } } \ 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..3310193f 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 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) + { + 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 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) + { + 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..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 @@ -72,4 +73,10 @@ + + + Always + + + 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 ddf1a04d..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,19 +106,51 @@ 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()?.ToObservable(); + return DeliveryClient?.GetItemsFeed(parameters).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) + { + return DeliveryClient?.GetItemUsedIn(codename, parameters).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) + { + return DeliveryClient?.GetAssetUsedIn(codename, parameters).ToObservable(); } /// @@ -211,7 +245,6 @@ public IObservable GetLanguagesObservable(params IQueryParameter[] pa #endregion #region "Private methods" - 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.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..c89fc577 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,58 @@ 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 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) + { + 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); + + 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 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) + { + if (codename == null) + { + throw new ArgumentNullException(nameof(codename), "The codename of an asset is not specified."); + } + + 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 +551,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)