From 7a353a20f9eaf927248c2bb08ff8770a93b1ef0c Mon Sep 17 00:00:00 2001 From: Tobias Kamenicky Date: Tue, 24 Sep 2019 12:51:57 +0200 Subject: [PATCH 1/4] Detect stale content --- .../DeliveryObservableProxy.cs | 6 +- .../DeliveryClientTests.cs | 354 +++++++++++++++++- Kentico.Kontent.Delivery/DeliveryClient.cs | 73 ++-- Kentico.Kontent.Delivery/IDeliveryClient.cs | 16 +- .../Responses/AbstractResponse.cs | 27 +- .../Responses/ApiResponse.cs | 40 ++ .../Responses/DeliveryElementResponse.cs | 33 ++ .../Responses/DeliveryItemListingResponse.cs | 49 ++- .../Responses/DeliveryItemResponse.cs | 47 +-- .../DeliveryTaxonomyListingResponse.cs | 22 +- .../Responses/DeliveryTaxonomyResponse.cs | 33 ++ .../Responses/DeliveryTypeListingResponse.cs | 22 +- .../Responses/DeliveryTypeResponse.cs | 33 ++ .../DeliveryItemListingResponse.cs | 42 +-- .../StrongTyping/DeliveryItemResponse.cs | 46 +-- 15 files changed, 664 insertions(+), 179 deletions(-) create mode 100644 Kentico.Kontent.Delivery/Responses/ApiResponse.cs create mode 100644 Kentico.Kontent.Delivery/Responses/DeliveryElementResponse.cs create mode 100644 Kentico.Kontent.Delivery/Responses/DeliveryTaxonomyResponse.cs create mode 100644 Kentico.Kontent.Delivery/Responses/DeliveryTypeResponse.cs diff --git a/Kentico.Kontent.Delivery.Rx/DeliveryObservableProxy.cs b/Kentico.Kontent.Delivery.Rx/DeliveryObservableProxy.cs index ef4d93a3..721d5c79 100644 --- a/Kentico.Kontent.Delivery.Rx/DeliveryObservableProxy.cs +++ b/Kentico.Kontent.Delivery.Rx/DeliveryObservableProxy.cs @@ -176,7 +176,7 @@ public IObservable GetTypesJsonObservable(params string[] parameters) /// The that represents the content type with the specified codename. public IObservable GetTypeObservable(string codename) { - return GetObservableOfOne(() => DeliveryClient?.GetTypeAsync(codename)?.Result); + return GetObservableOfOne(() => DeliveryClient?.GetTypeAsync(codename)?.Result.Type); } /// @@ -206,7 +206,7 @@ public IObservable GetTypesObservable(IEnumerable /// An that represents the content element with the specified codename, that is a part of a content type with the specified codename. public IObservable GetElementObservable(string contentTypeCodename, string contentElementCodename) { - return GetObservableOfOne(() => DeliveryClient?.GetContentElementAsync(contentTypeCodename, contentElementCodename)?.Result); + return GetObservableOfOne(() => DeliveryClient?.GetContentElementAsync(contentTypeCodename, contentElementCodename)?.Result.Element); } /// @@ -236,7 +236,7 @@ public IObservable GetTaxonomiesJsonObservable(params string[] paramete /// The that represents the taxonomy group with the specified codename. public IObservable GetTaxonomyObservable(string codename) { - return GetObservableOfOne(() => DeliveryClient?.GetTaxonomyAsync(codename)?.Result); + return GetObservableOfOne(() => DeliveryClient?.GetTaxonomyAsync(codename)?.Result.Taxonomy); } /// diff --git a/Kentico.Kontent.Delivery.Tests/DeliveryClientTests.cs b/Kentico.Kontent.Delivery.Tests/DeliveryClientTests.cs index a892ba0d..6110e854 100644 --- a/Kentico.Kontent.Delivery.Tests/DeliveryClientTests.cs +++ b/Kentico.Kontent.Delivery.Tests/DeliveryClientTests.cs @@ -183,8 +183,8 @@ public async void GetTypeAsync() var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); - var articleType = await client.GetTypeAsync("article"); - var coffeeType = await client.GetTypeAsync("coffee"); + var articleType = (await client.GetTypeAsync("article")).Type; + var coffeeType = (await client.GetTypeAsync("coffee")).Type; var taxonomyElement = articleType.Elements["personas"]; var processingTaxonomyElement = coffeeType.Elements["processing"]; @@ -251,9 +251,9 @@ public async void GetContentElementAsync() var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); - var element = await client.GetContentElementAsync(Article.Codename, Article.TitleCodename); - var personasTaxonomyElement = await client.GetContentElementAsync(Article.Codename, Article.PersonasCodename); - var processingTaxonomyElement = await client.GetContentElementAsync(Coffee.Codename, Coffee.ProcessingCodename); + var element = (await client.GetContentElementAsync(Article.Codename, Article.TitleCodename)).Element; + var personasTaxonomyElement = (await client.GetContentElementAsync(Article.Codename, Article.PersonasCodename)).Element; + var processingTaxonomyElement = (await client.GetContentElementAsync(Coffee.Codename, Coffee.ProcessingCodename)).Element; Assert.Equal(Article.TitleCodename, element.Codename); Assert.Equal(Article.PersonasCodename, personasTaxonomyElement.TaxonomyGroup); @@ -284,7 +284,7 @@ public async void GetTaxonomyAsync() var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); - var taxonomy = await client.GetTaxonomyAsync("personas"); + var taxonomy = (await client.GetTaxonomyAsync("personas")).Taxonomy; var personasTerms = taxonomy.Terms.ToList(); var coffeeExpertTerms = personasTerms[0].Terms.ToList(); @@ -888,6 +888,348 @@ public async void CorrectSdkVersionHeaderAdded() _mockHttp.VerifyNoOutstandingExpectation(); } + [Fact] + public async void GetItemAsync_ApiReturnsStaleContent_ResponseIndicatesStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "1") + }; + + _mockHttp + .When($"{_baseUrl}/items/test") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetItemAsync("test"); + + Assert.True(response.HasStaleContent); + } + + [Fact] + public async void GetItemAsync_ApiDoesNotReturnStaleContent_ResponseDoesNotIndicateStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "0") + }; + + _mockHttp + .When($"{_baseUrl}/items/test") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetItemAsync("test"); + + Assert.False(response.HasStaleContent); + } + + [Fact] + public async void GetItemsAsync_ApiReturnsStaleContent_ResponseIndicatesStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "1") + }; + + _mockHttp + .When($"{_baseUrl}/items") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetItemsAsync(); + + Assert.True(response.HasStaleContent); + } + + [Fact] + public async void GetItemsAsync_ApiDoesNotReturnStaleContent_ResponseDoesNotIndicateStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "0") + }; + + _mockHttp + .When($"{_baseUrl}/items") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetItemsAsync(); + + Assert.False(response.HasStaleContent); + } + + [Fact] + public async void GetCustomItemAsync_ApiReturnsStaleContent_ResponseIndicatesStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "1") + }; + + _mockHttp + .When($"{_baseUrl}/items/test") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetItemAsync("test"); + + Assert.True(response.HasStaleContent); + } + + [Fact] + public async void GetCustomItemAsync_ApiDoesNotReturnStaleContent_ResponseDoesNotIndicateStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "0") + }; + + _mockHttp + .When($"{_baseUrl}/items/test") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetItemAsync("test"); + + Assert.False(response.HasStaleContent); + } + + [Fact] + public async void GetCustomItemsAsync_ApiReturnsStaleContent_ResponseIndicatesStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "1") + }; + + _mockHttp + .When($"{_baseUrl}/items") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetItemsAsync(); + + Assert.True(response.HasStaleContent); + } + + [Fact] + public async void GetCustomItemsAsync_ApiDoesNotReturnStaleContent_ResponseDoesNotIndicateStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "0") + }; + + _mockHttp + .When($"{_baseUrl}/items") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetItemsAsync(); + + Assert.False(response.HasStaleContent); + } + + [Fact] + public async void GetTypeAsync_ApiReturnsStaleContent_ResponseIndicatesStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "1") + }; + + _mockHttp + .When($"{_baseUrl}/types/test") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetTypeAsync("test"); + + Assert.True(response.HasStaleContent); + } + + [Fact] + public async void GetTypeAsync_ApiDoesNotReturnStaleContent_ResponseDoesNotIndicateStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "0") + }; + + _mockHttp + .When($"{_baseUrl}/types/test") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetTypeAsync("test"); + + Assert.False(response.HasStaleContent); + } + + [Fact] + public async void GetTypesAsync_ApiReturnsStaleContent_ResponseIndicatesStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "1") + }; + + _mockHttp + .When($"{_baseUrl}/types") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetTypesAsync(); + + Assert.True(response.HasStaleContent); + } + + [Fact] + public async void GetTypesAsync_ApiDoesNotReturnStaleContent_ResponseDoesNotIndicateStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "0") + }; + + _mockHttp + .When($"{_baseUrl}/types") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetTypesAsync(); + + Assert.False(response.HasStaleContent); + } + + [Fact] + public async void GetTaxonomyAsync_ApiReturnsStaleContent_ResponseIndicatesStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "1") + }; + + _mockHttp + .When($"{_baseUrl}/taxonomies/test") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetTaxonomyAsync("test"); + + Assert.True(response.HasStaleContent); + } + + [Fact] + public async void GetTaxonomyAsync_ApiDoesNotReturnStaleContent_ResponseDoesNotIndicateStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "0") + }; + + _mockHttp + .When($"{_baseUrl}/taxonomies/test") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetTaxonomyAsync("test"); + + Assert.False(response.HasStaleContent); + } + + [Fact] + public async void GetTaxonomiesAsync_ApiReturnsStaleContent_ResponseIndicatesStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "1") + }; + + _mockHttp + .When($"{_baseUrl}/taxonomies") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetTaxonomiesAsync(); + + Assert.True(response.HasStaleContent); + } + + [Fact] + public async void GetTaxonomiesAsync_ApiDoesNotReturnStaleContent_ResponseDoesNotIndicateStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "0") + }; + + _mockHttp + .When($"{_baseUrl}/taxonomies") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetTaxonomiesAsync(); + + Assert.False(response.HasStaleContent); + } + + [Fact] + public async void GetElementAsync_ApiReturnsStaleContent_ResponseIndicatesStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "1") + }; + + _mockHttp + .When($"{_baseUrl}/types/test/elements/test") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetContentElementAsync("test", "test"); + + Assert.True(response.HasStaleContent); + } + + [Fact] + public async void GetElementAsync_ApiDoesNotReturnStaleContent_ResponseDoesNotIndicateStaleContent() + { + var headers = new[] + { + new KeyValuePair("X-Stale-Content", "0") + }; + + _mockHttp + .When($"{_baseUrl}/types/test/elements/test") + .Respond(headers, "application/json", "{ }"); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var response = await client.GetContentElementAsync("test", "test"); + + Assert.False(response.HasStaleContent); + } + [Fact] [Trait("Issue", "146")] public async void InitializeMultipleInlineContentItemsResolvers() diff --git a/Kentico.Kontent.Delivery/DeliveryClient.cs b/Kentico.Kontent.Delivery/DeliveryClient.cs index d26d0204..91bd72d2 100644 --- a/Kentico.Kontent.Delivery/DeliveryClient.cs +++ b/Kentico.Kontent.Delivery/DeliveryClient.cs @@ -83,7 +83,7 @@ public async Task GetItemJsonAsync(string codename, params string[] par var endpointUrl = UrlBuilder.GetItemUrl(codename, parameters); - return await GetDeliverResponseAsync(endpointUrl); + return (await GetDeliverResponseAsync(endpointUrl)).Content; } /// @@ -95,7 +95,7 @@ public async Task GetItemsJsonAsync(params string[] parameters) { var endpointUrl = UrlBuilder.GetItemsUrl(parameters); - return await GetDeliverResponseAsync(endpointUrl); + return (await GetDeliverResponseAsync(endpointUrl)).Content; } /// @@ -142,7 +142,7 @@ public async Task GetItemAsync(string codename, IEnumerabl var endpointUrl = UrlBuilder.GetItemUrl(codename, parameters); var response = await GetDeliverResponseAsync(endpointUrl); - return new DeliveryItemResponse(response, ModelProvider, ContentLinkUrlResolver, endpointUrl); + return new DeliveryItemResponse(response, ModelProvider, ContentLinkUrlResolver); } /// @@ -162,7 +162,7 @@ public async Task> GetItemAsync(string codename, IEnu var endpointUrl = UrlBuilder.GetItemUrl(codename, parameters); var response = await GetDeliverResponseAsync(endpointUrl); - return new DeliveryItemResponse(response, ModelProvider, endpointUrl); + return new DeliveryItemResponse(response, ModelProvider); } /// @@ -185,7 +185,7 @@ public async Task GetItemsAsync(IEnumerable @@ -211,7 +211,7 @@ public async Task> GetItemsAsync(IEnumerable(response, ModelProvider, endpointUrl); + return new DeliveryItemListingResponse(response, ModelProvider); } /// @@ -233,7 +233,7 @@ public async Task GetTypeJsonAsync(string codename) var endpointUrl = UrlBuilder.GetTypeUrl(codename); - return await GetDeliverResponseAsync(endpointUrl); + return (await GetDeliverResponseAsync(endpointUrl)).Content; } /// @@ -245,15 +245,15 @@ public async Task GetTypesJsonAsync(params string[] parameters) { var endpointUrl = UrlBuilder.GetTypesUrl(parameters); - return await GetDeliverResponseAsync(endpointUrl); + return (await GetDeliverResponseAsync(endpointUrl)).Content; } /// - /// Returns a content type. + /// Gets a content type by its codename. /// /// The codename of a content type. - /// The content type with the specified codename. - public async Task GetTypeAsync(string codename) + /// The instance that contains the content type with the specified codename. + public async Task GetTypeAsync(string codename) { if (codename == null) { @@ -268,39 +268,39 @@ public async Task GetTypeAsync(string codename) var endpointUrl = UrlBuilder.GetTypeUrl(codename); var response = await GetDeliverResponseAsync(endpointUrl); - return new ContentType(response); + return new DeliveryTypeResponse(response); } /// - /// Returns content types. + /// Returns content types that match the optional filtering parameters. /// /// An array that contains zero or more query parameters, for example, for paging. - /// The instance that represents the content types. If no query parameters are specified, all content types are returned. + /// The instance that contains the content types. If no query parameters are specified, all content types are returned. public async Task GetTypesAsync(params IQueryParameter[] parameters) { return await GetTypesAsync((IEnumerable)parameters); } /// - /// Returns content types. + /// Returns content types that match the optional filtering parameters. /// /// A collection of query parameters, for example, for paging. - /// The instance that represents the content types. If no query parameters are specified, all content types are returned. + /// The instance that contains the content types. If no query parameters are specified, all content types are returned. public async Task GetTypesAsync(IEnumerable parameters) { var endpointUrl = UrlBuilder.GetTypesUrl(parameters); var response = await GetDeliverResponseAsync(endpointUrl); - return new DeliveryTypeListingResponse(response, endpointUrl); + return new DeliveryTypeListingResponse(response); } /// - /// Returns a content element. + /// Returns a content type element. /// /// The codename of the content type. - /// The codename of the content element. - /// A content element with the specified codename that is a part of a content type with the specified codename. - public async Task GetContentElementAsync(string contentTypeCodename, string contentElementCodename) + /// The codename of the content type element. + /// The instance that contains the specified content type element. + public async Task GetContentElementAsync(string contentTypeCodename, string contentElementCodename) { if (contentTypeCodename == null) { @@ -325,12 +325,9 @@ public async Task GetContentElementAsync(string contentTypeCoden var endpointUrl = UrlBuilder.GetContentElementUrl(contentTypeCodename, contentElementCodename); var response = await GetDeliverResponseAsync(endpointUrl); - var elementCodename = response["codename"].ToString(); - - return new ContentElement(response, elementCodename); + return new DeliveryElementResponse(response); } - /// /// Returns a taxonomy group as JSON data. /// @@ -350,7 +347,7 @@ public async Task GetTaxonomyJsonAsync(string codename) var endpointUrl = UrlBuilder.GetTaxonomyUrl(codename); - return await GetDeliverResponseAsync(endpointUrl); + return (await GetDeliverResponseAsync(endpointUrl)).Content; } /// @@ -362,15 +359,15 @@ public async Task GetTaxonomiesJsonAsync(params string[] parameters) { var endpointUrl = UrlBuilder.GetTaxonomiesUrl(parameters); - return await GetDeliverResponseAsync(endpointUrl); + return (await GetDeliverResponseAsync(endpointUrl)).Content; } /// /// Returns a taxonomy group. /// /// The codename of a taxonomy group. - /// The taxonomy group with the specified codename. - public async Task GetTaxonomyAsync(string codename) + /// The instance that contains the taxonomy group with the specified codename. + public async Task GetTaxonomyAsync(string codename) { if (codename == null) { @@ -385,7 +382,7 @@ public async Task GetTaxonomyAsync(string codename) var endpointUrl = UrlBuilder.GetTaxonomyUrl(codename); var response = await GetDeliverResponseAsync(endpointUrl); - return new TaxonomyGroup(response); + return new DeliveryTaxonomyResponse(response); } /// @@ -408,10 +405,10 @@ public async Task GetTaxonomiesAsync(IEnumerabl var endpointUrl = UrlBuilder.GetTaxonomiesUrl(parameters); var response = await GetDeliverResponseAsync(endpointUrl); - return new DeliveryTaxonomyListingResponse(response, endpointUrl); + return new DeliveryTaxonomyListingResponse(response); } - private async Task GetDeliverResponseAsync(string endpointUrl) + private async Task GetDeliverResponseAsync(string endpointUrl) { if (DeliveryOptions.UsePreviewApi && DeliveryOptions.UseSecuredProductionApi) { @@ -468,13 +465,14 @@ private bool UsePreviewApi() return DeliveryOptions.UsePreviewApi && !string.IsNullOrEmpty(DeliveryOptions.PreviewApiKey); } - private async Task GetResponseContent(HttpResponseMessage httpResponseMessage) + private async Task GetResponseContent(HttpResponseMessage httpResponseMessage) { if (httpResponseMessage?.StatusCode == HttpStatusCode.OK) { - var content = await httpResponseMessage.Content?.ReadAsStringAsync(); + var content = JObject.Parse(await httpResponseMessage.Content?.ReadAsStringAsync()); + var hasStaleContent = HasStaleContent(httpResponseMessage); - return JObject.Parse(content); + return new ApiResponse(content, hasStaleContent: hasStaleContent, requestUrl: httpResponseMessage.RequestMessage.RequestUri.AbsoluteUri); } string faultContent = null; @@ -488,6 +486,11 @@ private async Task GetResponseContent(HttpResponseMessage httpResponseM throw new DeliveryException(httpResponseMessage, "Either the retry policy was disabled or all retry attempts were depleted.\nFault content:\n" + faultContent); } + private bool HasStaleContent(HttpResponseMessage httpResponseMessage) + { + return httpResponseMessage.Headers.TryGetValues("X-Stale-Content", out var values) && values.Contains("1", StringComparer.Ordinal); + } + internal IEnumerable ExtractParameters(IEnumerable parameters = null) { var enhancedParameters = parameters != null diff --git a/Kentico.Kontent.Delivery/IDeliveryClient.cs b/Kentico.Kontent.Delivery/IDeliveryClient.cs index 9b1519cb..0656c3a8 100644 --- a/Kentico.Kontent.Delivery/IDeliveryClient.cs +++ b/Kentico.Kontent.Delivery/IDeliveryClient.cs @@ -106,8 +106,8 @@ public interface IDeliveryClient /// Returns a content type. /// /// The codename of a content type. - /// The content type with the specified codename. - Task GetTypeAsync(string codename); + /// The instance that contains the content type with the specified codename. + Task GetTypeAsync(string codename); /// /// Returns content types. @@ -124,12 +124,12 @@ public interface IDeliveryClient Task GetTypesAsync(IEnumerable parameters); /// - /// Returns a content element. + /// Returns a content type element. /// /// The codename of the content type. - /// The codename of the content element. - /// A content element with the specified codename that is a part of a content type with the specified codename. - Task GetContentElementAsync(string contentTypeCodename, string contentElementCodename); + /// The codename of the content type element. + /// The instance that contains the specified content type element. + Task GetContentElementAsync(string contentTypeCodename, string contentElementCodename); /// /// Returns a taxonomy group as JSON data. @@ -149,8 +149,8 @@ public interface IDeliveryClient /// Returns a taxonomy group. /// /// The codename of a taxonomy group. - /// The taxonomy group with the specified codename. - Task GetTaxonomyAsync(string codename); + /// The instance that contains the taxonomy group with the specified codename. + Task GetTaxonomyAsync(string codename); /// /// Returns taxonomy groups. diff --git a/Kentico.Kontent.Delivery/Responses/AbstractResponse.cs b/Kentico.Kontent.Delivery/Responses/AbstractResponse.cs index ec31040a..4ce87c1e 100644 --- a/Kentico.Kontent.Delivery/Responses/AbstractResponse.cs +++ b/Kentico.Kontent.Delivery/Responses/AbstractResponse.cs @@ -1,27 +1,34 @@ namespace Kentico.Kontent.Delivery { /// - /// Base class for response objects. + /// Represents a successful response from Kentico Kontent Delivery API. /// public abstract class AbstractResponse { - #region "Debugging properties" + /// + /// The successful JSON response from Kentico Kontent Delivery API. + /// + protected readonly ApiResponse _response; /// - /// The URL of the request sent to the Kentico Kontent endpoint by the . - /// Useful for debugging. + /// Gets a value that determines whether content is stale. + /// Stale content indicates that there is a more recent version, but it will become available later. + /// Stale content should be cached only for a limited period of time. /// - public string ApiUrl { get; protected set; } + public bool HasStaleContent => _response.HasStaleContent; - #endregion + /// + /// Gets the URL used to retrieve this response for debugging purposes. + /// + public string ApiUrl => _response.RequestUrl; /// - /// Default constructor. + /// Initializes a new instance of the class. /// - /// API URL used to communicate with the underlying Kentico Kontent endpoint. - protected AbstractResponse(string apiUrl) + /// A successful JSON response from Kentico Kontent Delivery API. + protected AbstractResponse(ApiResponse response) { - ApiUrl = apiUrl; + _response = response; } } } diff --git a/Kentico.Kontent.Delivery/Responses/ApiResponse.cs b/Kentico.Kontent.Delivery/Responses/ApiResponse.cs new file mode 100644 index 00000000..3b8014c8 --- /dev/null +++ b/Kentico.Kontent.Delivery/Responses/ApiResponse.cs @@ -0,0 +1,40 @@ +using Newtonsoft.Json.Linq; + +namespace Kentico.Kontent.Delivery +{ + /// + /// Represents a successful JSON response from Kentico Kontent Delivery API. + /// + public sealed class ApiResponse + { + /// + /// Gets JSON content. + /// + public JObject Content { get; } + + /// + /// Gets a value that determines whether content is stale. + /// Stale content indicates that there is a more recent version, but it will become available later. + /// Stale content should be cached only for a limited period of time. + /// + public bool HasStaleContent { get; } + + /// + /// Gets the URL used to retrieve this response for debugging purposes. + /// + public string RequestUrl { get; } + + /// + /// Initializes a new instance of the class. + /// + /// JSON content. + /// Specifies whether content is stale. + /// The URL used to retrieve this response. + internal ApiResponse(JObject content, bool hasStaleContent, string requestUrl) + { + Content = content; + HasStaleContent = hasStaleContent; + RequestUrl = requestUrl; + } + } +} diff --git a/Kentico.Kontent.Delivery/Responses/DeliveryElementResponse.cs b/Kentico.Kontent.Delivery/Responses/DeliveryElementResponse.cs new file mode 100644 index 00000000..97e9a06f --- /dev/null +++ b/Kentico.Kontent.Delivery/Responses/DeliveryElementResponse.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading; + +namespace Kentico.Kontent.Delivery +{ + /// + /// Represents a response from Kentico Kontent Delivery API that contains a content type element. + /// + public sealed class DeliveryElementResponse : AbstractResponse + { + private readonly Lazy _element; + + /// + /// Gets the content type element. + /// + public ContentElement Element => _element.Value; + + /// + /// Initializes a new instance of the class. + /// + /// The response from Kentico Kontent Delivery API that contains a content type element. + internal DeliveryElementResponse(ApiResponse response) : base(response) + { + _element = new Lazy(() => new ContentElement(_response.Content, _response.Content.Value("codename")), LazyThreadSafetyMode.PublicationOnly); + } + + /// + /// Implicitly converts the specified to a content type element. + /// + /// The response to convert. + public static implicit operator ContentElement(DeliveryElementResponse response) => response.Element; + } +} diff --git a/Kentico.Kontent.Delivery/Responses/DeliveryItemListingResponse.cs b/Kentico.Kontent.Delivery/Responses/DeliveryItemListingResponse.cs index 39ae049a..e6435a89 100644 --- a/Kentico.Kontent.Delivery/Responses/DeliveryItemListingResponse.cs +++ b/Kentico.Kontent.Delivery/Responses/DeliveryItemListingResponse.cs @@ -1,6 +1,8 @@ -using Newtonsoft.Json.Linq; +using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using Newtonsoft.Json.Linq; namespace Kentico.Kontent.Delivery { @@ -9,58 +11,49 @@ namespace Kentico.Kontent.Delivery /// public sealed class DeliveryItemListingResponse : AbstractResponse { - private readonly JToken _response; private readonly IModelProvider _modelProvider; private readonly IContentLinkUrlResolver _contentLinkUrlResolver; - private Pagination _pagination; - private IReadOnlyList _items; - private dynamic _linkedItems; + private readonly Lazy _pagination; + private readonly Lazy> _items; + private readonly Lazy _linkedItems; /// /// Gets paging information. /// - public Pagination Pagination - { - get { return _pagination ?? (_pagination = _response["pagination"].ToObject()); } - } + public Pagination Pagination => _pagination.Value; /// - /// Gets a list of content items. + /// Gets a read-only list of content items. /// - public IReadOnlyList Items - { - get { return _items ?? (_items = ((JArray)_response["items"]).Select(source => new ContentItem(source, _response["modular_content"], _contentLinkUrlResolver, _modelProvider)).ToList().AsReadOnly()); } - } + public IReadOnlyList Items => _items.Value; /// /// Gets the dynamic view of the JSON response where linked items and their properties can be retrieved by name, for example LinkedItems.about_us.elements.description.value. /// - public dynamic LinkedItems - { - get { return _linkedItems ?? (_linkedItems = JObject.Parse(_response["modular_content"].ToString())); } - } + public dynamic LinkedItems => _linkedItems.Value; /// - /// Initializes a new instance of the class with information from a response. + /// Initializes a new instance of the class. /// - /// A response from Kentico Kontent Delivery API that contains a list of content items. - /// /// An instance of an object that can JSON responses into strongly typed CLR objects - /// An instance of an object that can resolve links in rich text elements - /// API URL used to communicate with the underlying Kentico Kontent endpoint. - internal DeliveryItemListingResponse(JToken response, IModelProvider modelProvider, IContentLinkUrlResolver contentLinkUrlResolver, string apiUrl) : base(apiUrl) + /// The response from Kentico Kontent Delivery API that contains a list of content items. + /// The provider that can convert JSON responses into instances of .NET types. + /// The resolver that can generate URLs for links in rich text elements. + internal DeliveryItemListingResponse(ApiResponse response, IModelProvider modelProvider, IContentLinkUrlResolver contentLinkUrlResolver) : base(response) { - _response = response; _modelProvider = modelProvider; _contentLinkUrlResolver = contentLinkUrlResolver; + _pagination = new Lazy(() => _response.Content["pagination"].ToObject(), LazyThreadSafetyMode.PublicationOnly); + _items = new Lazy>(() => ((JArray)_response.Content["items"]).Select(source => new ContentItem(source, _response.Content["modular_content"], _contentLinkUrlResolver, _modelProvider)).ToList().AsReadOnly(), LazyThreadSafetyMode.PublicationOnly); + _linkedItems = new Lazy(() => (JObject)_response.Content["modular_content"].DeepClone(), LazyThreadSafetyMode.PublicationOnly); } /// - /// Casts DeliveryItemListingResponse to its generic version. Use this method only when the listed items are of the same type. + /// Casts this response to a generic one. To succeed all items must be of the same type. /// - /// Target type. + /// The object type that the items will be deserialized to. public DeliveryItemListingResponse CastTo() { - return new DeliveryItemListingResponse(_response, _modelProvider, ApiUrl); + return new DeliveryItemListingResponse(_response, _modelProvider); } } } diff --git a/Kentico.Kontent.Delivery/Responses/DeliveryItemResponse.cs b/Kentico.Kontent.Delivery/Responses/DeliveryItemResponse.cs index 0a26773f..61f319a1 100644 --- a/Kentico.Kontent.Delivery/Responses/DeliveryItemResponse.cs +++ b/Kentico.Kontent.Delivery/Responses/DeliveryItemResponse.cs @@ -1,4 +1,6 @@ -using Newtonsoft.Json.Linq; +using System; +using System.Threading; +using Newtonsoft.Json.Linq; namespace Kentico.Kontent.Delivery { @@ -7,49 +9,48 @@ namespace Kentico.Kontent.Delivery /// public sealed class DeliveryItemResponse : AbstractResponse { - private readonly JToken _response; private readonly IModelProvider _modelProvider; private readonly IContentLinkUrlResolver _contentLinkUrlResolver; - private dynamic _linkedItems; - private ContentItem _item; + private readonly Lazy _item; + private readonly Lazy _linkedItems; /// - /// Gets the content item from the response. + /// Gets the content item. /// - public ContentItem Item - { - get { return _item ?? (_item = new ContentItem(_response["item"], _response["modular_content"], _contentLinkUrlResolver, _modelProvider)); } - } + public ContentItem Item => _item.Value; /// /// Gets the dynamic view of the JSON response where linked items and their properties can be retrieved by name, for example LinkedItems.about_us.elements.description.value. /// - public dynamic LinkedItems - { - get { return _linkedItems ?? (_linkedItems = JObject.Parse(_response["modular_content"].ToString())); } - } + public dynamic LinkedItems => _linkedItems.Value; /// - /// Initializes a new instance of the class with information from a response. + /// Initializes a new instance of the class. /// - /// A response from Kentico Kontent Delivery API that contains a content item. - /// /// An instance of an object that can JSON responses into strongly typed CLR objects - /// An instance of an object that can resolve links in rich text elements - /// API URL used to communicate with the underlying Kentico Kontent endpoint. - internal DeliveryItemResponse(JToken response, IModelProvider modelProvider, IContentLinkUrlResolver contentLinkUrlResolver, string apiUrl) : base(apiUrl) + /// The response from Kentico Kontent Delivery API that contains a content item. + /// The provider that can convert JSON responses into instances of .NET types. + /// The resolver that can generate URLs for links in rich text elements. + internal DeliveryItemResponse(ApiResponse response, IModelProvider modelProvider, IContentLinkUrlResolver contentLinkUrlResolver) : base(response) { - _response = response; _modelProvider = modelProvider; _contentLinkUrlResolver = contentLinkUrlResolver; + _item = new Lazy(() => new ContentItem(_response.Content["item"], _response.Content["modular_content"], _contentLinkUrlResolver, _modelProvider), LazyThreadSafetyMode.PublicationOnly); + _linkedItems = new Lazy(() => (JObject)_response.Content["modular_content"].DeepClone(), LazyThreadSafetyMode.PublicationOnly); } /// - /// Casts DeliveryItemResponse to its generic version. + /// Casts this response to a generic one. /// - /// Target type. + /// The object type that the item will be deserialized to. public DeliveryItemResponse CastTo() { - return new DeliveryItemResponse(_response, _modelProvider, ApiUrl); + return new DeliveryItemResponse(_response, _modelProvider); } + + /// + /// Implicitly converts the specified to a content item. + /// + /// The response to convert. + public static implicit operator ContentItem(DeliveryItemResponse response) => response.Item; } } diff --git a/Kentico.Kontent.Delivery/Responses/DeliveryTaxonomyListingResponse.cs b/Kentico.Kontent.Delivery/Responses/DeliveryTaxonomyListingResponse.cs index 224b6da0..f531133a 100644 --- a/Kentico.Kontent.Delivery/Responses/DeliveryTaxonomyListingResponse.cs +++ b/Kentico.Kontent.Delivery/Responses/DeliveryTaxonomyListingResponse.cs @@ -1,6 +1,8 @@ using Newtonsoft.Json.Linq; +using System; using System.Collections.Generic; using System.Linq; +using System.Threading; namespace Kentico.Kontent.Delivery { @@ -9,25 +11,27 @@ namespace Kentico.Kontent.Delivery /// public sealed class DeliveryTaxonomyListingResponse : AbstractResponse { + private readonly Lazy _pagination; + private readonly Lazy> _taxonomies; + /// /// Gets paging information. /// - public Pagination Pagination { get; } + public Pagination Pagination => _pagination.Value; /// - /// Gets a list of taxonomy groups. + /// Gets a read-only list of taxonomy groups. /// - public IReadOnlyList Taxonomies { get; } + public IReadOnlyList Taxonomies => _taxonomies.Value; /// - /// Initializes a new instance of the class with information from a response. + /// Initializes a new instance of the class. /// - /// A response from Kentico Kontent Delivery API that contains a list of taxonomy groups. - /// /// API URL used to communicate with the underlying Kentico Kontent endpoint. - internal DeliveryTaxonomyListingResponse(JToken response, string apiUrl) : base(apiUrl) + /// The response from Kentico Kontent Delivery API that contains a list of taxonomy groups. + internal DeliveryTaxonomyListingResponse(ApiResponse response) : base(response) { - Pagination = response["pagination"].ToObject(); - Taxonomies = ((JArray)response["taxonomies"]).Select(type => new TaxonomyGroup(type)).ToList().AsReadOnly(); + _pagination = new Lazy(() => _response.Content["pagination"].ToObject(), LazyThreadSafetyMode.PublicationOnly); + _taxonomies = new Lazy>(() => ((JArray)_response.Content["taxonomies"]).Select(source => new TaxonomyGroup(source)).ToList().AsReadOnly(), LazyThreadSafetyMode.PublicationOnly); } } } diff --git a/Kentico.Kontent.Delivery/Responses/DeliveryTaxonomyResponse.cs b/Kentico.Kontent.Delivery/Responses/DeliveryTaxonomyResponse.cs new file mode 100644 index 00000000..da79279e --- /dev/null +++ b/Kentico.Kontent.Delivery/Responses/DeliveryTaxonomyResponse.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading; + +namespace Kentico.Kontent.Delivery +{ + /// + /// Represents a response from Kentico Kontent Delivery API that contains a taxonomy group. + /// + public sealed class DeliveryTaxonomyResponse : AbstractResponse + { + private readonly Lazy _taxonomy; + + /// + /// Gets the taxonomy group. + /// + public TaxonomyGroup Taxonomy => _taxonomy.Value; + + /// + /// Initializes a new instance of the class. + /// + /// The response from Kentico Kontent Delivery API that contains a taxonomy group. + internal DeliveryTaxonomyResponse(ApiResponse response) : base(response) + { + _taxonomy = new Lazy(() => new TaxonomyGroup(_response.Content), LazyThreadSafetyMode.PublicationOnly); + } + + /// + /// Implicitly converts the specified to a taxonomy group. + /// + /// The response to convert. + public static implicit operator TaxonomyGroup(DeliveryTaxonomyResponse response) => response.Taxonomy; + } +} diff --git a/Kentico.Kontent.Delivery/Responses/DeliveryTypeListingResponse.cs b/Kentico.Kontent.Delivery/Responses/DeliveryTypeListingResponse.cs index 0a63139d..145217da 100644 --- a/Kentico.Kontent.Delivery/Responses/DeliveryTypeListingResponse.cs +++ b/Kentico.Kontent.Delivery/Responses/DeliveryTypeListingResponse.cs @@ -1,6 +1,8 @@ using Newtonsoft.Json.Linq; +using System; using System.Collections.Generic; using System.Linq; +using System.Threading; namespace Kentico.Kontent.Delivery { @@ -9,25 +11,27 @@ namespace Kentico.Kontent.Delivery /// public sealed class DeliveryTypeListingResponse : AbstractResponse { + private readonly Lazy _pagination; + private readonly Lazy> _types; + /// /// Gets paging information. /// - public Pagination Pagination { get; } + public Pagination Pagination => _pagination.Value; /// - /// Gets a list of content types. + /// Gets a read-only list of content types. /// - public IReadOnlyList Types { get; } + public IReadOnlyList Types => _types.Value; /// - /// Initializes a new instance of the class with information from a response. + /// Initializes a new instance of the class. /// - /// A response from Kentico Kontent Delivery API that contains a list of content types. - /// API URL used to communicate with the underlying Kentico Kontent endpoint. - internal DeliveryTypeListingResponse(JToken response, string apiUrl) : base(apiUrl) + /// The response from Kentico Kontent Delivery API that contains a list of content types. + internal DeliveryTypeListingResponse(ApiResponse response) : base(response) { - Pagination = response["pagination"].ToObject(); - Types = ((JArray)response["types"]).Select(type => new ContentType(type)).ToList().AsReadOnly(); + _pagination = new Lazy(() => _response.Content["pagination"].ToObject(), LazyThreadSafetyMode.PublicationOnly); + _types = new Lazy>(() => ((JArray)_response.Content["types"]).Select(source => new ContentType(source)).ToList().AsReadOnly(), LazyThreadSafetyMode.PublicationOnly); } } } diff --git a/Kentico.Kontent.Delivery/Responses/DeliveryTypeResponse.cs b/Kentico.Kontent.Delivery/Responses/DeliveryTypeResponse.cs new file mode 100644 index 00000000..09194412 --- /dev/null +++ b/Kentico.Kontent.Delivery/Responses/DeliveryTypeResponse.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading; + +namespace Kentico.Kontent.Delivery +{ + /// + /// Represents a response from Kentico Kontent Delivery API that contains a content type. + /// + public sealed class DeliveryTypeResponse : AbstractResponse + { + private readonly Lazy _type; + + /// + /// Gets the content type. + /// + public ContentType Type => _type.Value; + + /// + /// Initializes a new instance of the class. + /// + /// The response from Kentico Kontent Delivery API that contains a content type. + internal DeliveryTypeResponse(ApiResponse response) : base(response) + { + _type = new Lazy(() => new ContentType(_response.Content), LazyThreadSafetyMode.PublicationOnly); + } + + /// + /// Implicitly converts the specified to a content type. + /// + /// The response to convert. + public static implicit operator ContentType(DeliveryTypeResponse response) => response.Type; + } +} diff --git a/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemListingResponse.cs b/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemListingResponse.cs index b9080fbd..d7c22e98 100644 --- a/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemListingResponse.cs +++ b/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemListingResponse.cs @@ -1,56 +1,48 @@ using Newtonsoft.Json.Linq; +using System; using System.Collections.Generic; using System.Linq; +using System.Threading; namespace Kentico.Kontent.Delivery { /// /// Represents a response from Kentico Kontent Delivery API that contains a list of content items. /// - /// Generic strong type of item representation. + /// The type of content items in the response. public sealed class DeliveryItemListingResponse : AbstractResponse { - private readonly JToken _response; private readonly IModelProvider _modelProvider; - private dynamic _linkedItems; - private Pagination _pagination; - private IReadOnlyList _items; + private readonly Lazy _pagination; + private readonly Lazy> _items; + private readonly Lazy _linkedItems; /// /// Gets paging information. /// - public Pagination Pagination - { - get { return _pagination ?? (_pagination = _response["pagination"].ToObject()); } - } + public Pagination Pagination => _pagination.Value; /// - /// Gets a list of content items. + /// Gets a read-only list of content items. /// - public IReadOnlyList Items - { - get { return _items ?? (_items = ((JArray)_response["items"]).Select(source => _modelProvider.GetContentItemModel(source, _response["modular_content"])).ToList().AsReadOnly()); } - } - + public IReadOnlyList Items => _items.Value; /// /// Gets the dynamic view of the JSON response where linked items and their properties can be retrieved by name, for example LinkedItems.about_us.elements.description.value. /// - public dynamic LinkedItems - { - get { return _linkedItems ?? (_linkedItems = JObject.Parse(_response["modular_content"].ToString())); } - } + public dynamic LinkedItems => _linkedItems.Value; /// - /// Initializes a new instance of the class with information from a response. + /// Initializes a new instance of the class. /// - /// A response from Kentico Kontent Delivery API that contains a list of content items. - /// - /// API URL used to communicate with the underlying Kentico Kontent endpoint. - internal DeliveryItemListingResponse(JToken response, IModelProvider modelProvider, string apiUrl) : base(apiUrl) + /// The response from Kentico Kontent Delivery API that contains a list of content items. + /// The provider that can convert JSON responses into instances of .NET types. + internal DeliveryItemListingResponse(ApiResponse response, IModelProvider modelProvider) : base(response) { - _response = response; _modelProvider = modelProvider; + _pagination = new Lazy(() => _response.Content["pagination"].ToObject(), LazyThreadSafetyMode.PublicationOnly); + _items = new Lazy>(() => ((JArray)_response.Content["items"]).Select(source => _modelProvider.GetContentItemModel(source, _response.Content["modular_content"])).ToList().AsReadOnly(), LazyThreadSafetyMode.PublicationOnly); + _linkedItems = new Lazy(() => (JObject)_response.Content["modular_content"].DeepClone(), LazyThreadSafetyMode.PublicationOnly); } } } diff --git a/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemResponse.cs b/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemResponse.cs index b860c766..608c23bf 100644 --- a/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemResponse.cs +++ b/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemResponse.cs @@ -1,45 +1,45 @@ using Newtonsoft.Json.Linq; +using System; +using System.Threading; namespace Kentico.Kontent.Delivery { /// - /// Represents a response from Kentico Kontent Delivery API that contains an content items. + /// Represents a response from Kentico Kontent Delivery API that contains a content item. /// - /// Generic strong type of item representation. + /// The type of a content item in the response. public sealed class DeliveryItemResponse : AbstractResponse { - private readonly JToken _response; private readonly IModelProvider _modelProvider; - private dynamic _linkedItems; - private T _item; + private readonly Lazy _item; + private readonly Lazy _linkedItems; /// - /// Gets a content item. + /// Gets the content item. /// - public T Item - { - get - { - if (_item == null) - { - _item = _modelProvider.GetContentItemModel(_response["item"], _response["modular_content"]); - } - return _item; - } - } + public T Item => _item.Value; /// /// Gets the dynamic view of the JSON response where linked items and their properties can be retrieved by name, for example LinkedItems.about_us.elements.description.value. /// - public dynamic LinkedItems - { - get { return _linkedItems ?? (_linkedItems = JObject.Parse(_response["modular_content"].ToString())); } - } + public dynamic LinkedItems => _linkedItems.Value; - internal DeliveryItemResponse(JToken response, IModelProvider modelProvider, string apiUrl) : base(apiUrl) + /// + /// Initializes a new instance of the class. + /// + /// The response from Kentico Kontent Delivery API that contains a content item. + /// The provider that can convert JSON responses into instances of .NET types. + internal DeliveryItemResponse(ApiResponse response, IModelProvider modelProvider) : base(response) { - _response = response; _modelProvider = modelProvider; + _item = new Lazy(() => _modelProvider.GetContentItemModel(_response.Content["item"], _response.Content["modular_content"]), LazyThreadSafetyMode.PublicationOnly); + _linkedItems = new Lazy(() => (JObject)_response.Content["modular_content"].DeepClone(), LazyThreadSafetyMode.PublicationOnly); } + + /// + /// Implicitly converts the specified to a content item. + /// + /// The response to convert. + public static implicit operator T(DeliveryItemResponse response) => response.Item; } } From 0bed0fff4f4cbfa52ad56e3f0ffa97be3efe12d5 Mon Sep 17 00:00:00 2001 From: Tobias Kamenicky Date: Wed, 4 Sep 2019 16:01:43 +0200 Subject: [PATCH 2/4] KCL-1219 Add support for new items-feed endpoint --- .../DeliveryObservableProxyTests.cs | 46 ++++ .../DeliveryObservableProxy.cs | 66 +++++ .../DeliveryClientTests.cs | 184 ++++++++++++++ .../DeliveryClient/articles_feed.json | 229 ++++++++++++++++++ .../DeliveryClient/articles_feed_1.json | 187 ++++++++++++++ .../DeliveryClient/articles_feed_2.json | 47 ++++ .../Kentico.Kontent.Delivery.Tests.csproj | 21 ++ .../Models/ArticlePartialItemModel.cs | 12 + Kentico.Kontent.Delivery/DeliveryClient.cs | 96 +++++++- .../DeliveryEndpointUrlBuilder.cs | 11 + .../HttpRequestHeadersExtensions.cs | 6 + .../HttpResponseHeadersExtensions.cs | 17 ++ Kentico.Kontent.Delivery/IDeliveryClient.cs | 30 +++ .../Responses/ApiResponse.cs | 11 +- .../Responses/DeliveryItemsFeed.cs | 50 ++++ .../Responses/DeliveryItemsFeedResponse.cs | 69 ++++++ .../Responses/FeedResponse.cs | 21 ++ .../StrongTyping/DeliveryItemsFeed.cs | 51 ++++ .../StrongTyping/DeliveryItemsFeedResponse.cs | 56 +++++ README.md | 59 +++++ 20 files changed, 1261 insertions(+), 8 deletions(-) create mode 100644 Kentico.Kontent.Delivery.Tests/Fixtures/DeliveryClient/articles_feed.json create mode 100644 Kentico.Kontent.Delivery.Tests/Fixtures/DeliveryClient/articles_feed_1.json create mode 100644 Kentico.Kontent.Delivery.Tests/Fixtures/DeliveryClient/articles_feed_2.json create mode 100644 Kentico.Kontent.Delivery.Tests/Models/ArticlePartialItemModel.cs create mode 100644 Kentico.Kontent.Delivery/Extensions/HttpResponseHeadersExtensions.cs create mode 100644 Kentico.Kontent.Delivery/Responses/DeliveryItemsFeed.cs create mode 100644 Kentico.Kontent.Delivery/Responses/DeliveryItemsFeedResponse.cs create mode 100644 Kentico.Kontent.Delivery/Responses/FeedResponse.cs create mode 100644 Kentico.Kontent.Delivery/StrongTyping/DeliveryItemsFeed.cs create mode 100644 Kentico.Kontent.Delivery/StrongTyping/DeliveryItemsFeedResponse.cs diff --git a/Kentico.Kontent.Delivery.Rx.Tests/DeliveryObservableProxyTests.cs b/Kentico.Kontent.Delivery.Rx.Tests/DeliveryObservableProxyTests.cs index d52f8f64..d7f086f0 100644 --- a/Kentico.Kontent.Delivery.Rx.Tests/DeliveryObservableProxyTests.cs +++ b/Kentico.Kontent.Delivery.Rx.Tests/DeliveryObservableProxyTests.cs @@ -91,6 +91,17 @@ public void ContentItemsRetrieved() Assert.All(items, item => AssertItemPropertiesNotNull(item)); } + [Fact] + public void ContentItemsFeedRetrieved() + { + var observable = new DeliveryObservableProxy(GetDeliveryClient(MockFeedItems)).GetItemsFeedObservable(); + var items = observable.ToEnumerable().ToList(); + + Assert.NotEmpty(items); + Assert.Equal(2, items.Count); + Assert.All(items, item => AssertItemPropertiesNotNull(item)); + } + [Fact] public void TypedItemsRetrieved() { @@ -102,6 +113,17 @@ public void TypedItemsRetrieved() Assert.All(items, article => AssertArticlePropertiesNotNull(article)); } + [Fact] + public void TypedItemsFeedRetrieved() + { + var observable = new DeliveryObservableProxy(GetDeliveryClient(MockFeedArticles)).GetItemsFeedObservable
(new ContainsFilter("elements.personas", "barista")); + var items = observable.ToEnumerable().ToList(); + + Assert.NotEmpty(items); + Assert.Equal(6, items.Count); + Assert.All(items, article => AssertArticlePropertiesNotNull(article)); + } + [Fact] public void RuntimeTypedItemsRetrieved() { @@ -113,6 +135,17 @@ public void RuntimeTypedItemsRetrieved() Assert.All(articles, article => AssertArticlePropertiesNotNull(article)); } + [Fact] + public void RuntimeTypedItemsFeedRetrieved() + { + var observable = new DeliveryObservableProxy(GetDeliveryClient(MockFeedArticles)).GetItemsFeedObservable
(new ContainsFilter("elements.personas", "barista")); + var articles = observable.ToEnumerable().ToList(); + + Assert.NotEmpty(articles); + Assert.All(articles, article => Assert.IsType
(article)); + Assert.All(articles, article => AssertArticlePropertiesNotNull(article)); + } + [Fact] public async void TypeJsonRetrieved() { @@ -252,6 +285,12 @@ private void MockItems() .Respond("application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}items.json"))); } + private void MockFeedItems() + { + mockHttp.When($"{baseUrl}/items-feed") + .Respond("application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}items.json"))); + } + private void MockArticles() { mockHttp.When($"{baseUrl}/items") @@ -259,6 +298,13 @@ private void MockArticles() .Respond("application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}articles.json"))); } + private void MockFeedArticles() + { + mockHttp.When($"{baseUrl}/items-feed") + .WithQueryString(new[] { new KeyValuePair("system.type", Article.Codename), new KeyValuePair("elements.personas[contains]", "barista") }) + .Respond("application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}articles.json"))); + } + private void MockType() { mockHttp.When($"{baseUrl}/types/{Article.Codename}") diff --git a/Kentico.Kontent.Delivery.Rx/DeliveryObservableProxy.cs b/Kentico.Kontent.Delivery.Rx/DeliveryObservableProxy.cs index 721d5c79..3bdda599 100644 --- a/Kentico.Kontent.Delivery.Rx/DeliveryObservableProxy.cs +++ b/Kentico.Kontent.Delivery.Rx/DeliveryObservableProxy.cs @@ -149,6 +149,72 @@ public IObservable GetItemsObservable(IEnumerable paramet return (DeliveryClient?.GetItemsAsync(parameters))?.Result?.Items?.ToObservable(); } + /// + /// Returns an observable of content items that match the optional filtering parameters. Items are enumerated in batches. + /// + /// A collection of query parameters, for example, for filtering or ordering. + /// The that represents the content items. If no query parameters are specified, all content items are returned. + public IObservable GetItemsFeedObservable(params IQueryParameter[] parameters) + { + return GetItemsFeedObservable((IEnumerable)parameters); + } + + /// + /// Returns an observable of content items that match the optional filtering parameters. Items are enumerated in batches. + /// + /// An array of query parameters, for example, for filtering or ordering. + /// The that represents the content items. If no query parameters are specified, all content items are returned. + public IObservable GetItemsFeedObservable(IEnumerable parameters) + { + var feed = DeliveryClient?.GetItemsFeed(parameters); + return feed == null ? null : EnumerateFeed()?.ToObservable(); + + IEnumerable EnumerateFeed() + { + while (feed.HasMoreResults) + { + foreach (var contentItem in feed.FetchNextBatchAsync().Result) + { + yield return contentItem; + } + } + } + } + + /// + /// Returns an observable of strongly typed content items that match the optional filtering parameters. Items are enumerated in batches. + /// + /// /// Type of the model. (Or if the return type is not yet known.) + /// A collection of query parameters, for example, for filtering or ordering. + /// The that represents the content items. If no query parameters are specified, all content items are returned. + public IObservable GetItemsFeedObservable(params IQueryParameter[] parameters) where T : class + { + return GetItemsFeedObservable((IEnumerable)parameters); + } + + /// + /// Returns an observable of strongly typed content items that match the optional filtering parameters. Items are enumerated in batches. + /// + /// /// Type of the model. (Or if the return type is not yet known.) + /// A collection of query parameters, for example, for filtering or ordering. + /// 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(); + + IEnumerable EnumerateFeed() + { + while (feed.HasMoreResults) + { + foreach (var contentItem in feed.FetchNextBatchAsync().Result) + { + yield return contentItem; + } + } + } + } + /// /// Returns an observable of a single content type as JSON data. /// diff --git a/Kentico.Kontent.Delivery.Tests/DeliveryClientTests.cs b/Kentico.Kontent.Delivery.Tests/DeliveryClientTests.cs index 6110e854..a8e10dd1 100644 --- a/Kentico.Kontent.Delivery.Tests/DeliveryClientTests.cs +++ b/Kentico.Kontent.Delivery.Tests/DeliveryClientTests.cs @@ -170,6 +170,86 @@ public async void GetItemsAsync() Assert.NotEmpty(response.Items); } + [Fact] + public void GetItemsFeed_DepthParameter_ThrowsArgumentException() + { + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + Assert.Throws(() => client.GetItemsFeed(new DepthParameter(2))); + } + + [Fact] + public void GetItemsFeed_LimitParameter_ThrowsArgumentException() + { + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + Assert.Throws(() => client.GetItemsFeed(new LimitParameter(2))); + } + + [Fact] + public void GetItemsFeed_SkipParameter_ThrowsArgumentException() + { + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + Assert.Throws(() => client.GetItemsFeed(new SkipParameter(2))); + } + + [Fact] + public async void GetItemsFeed_SingleBatch_FetchNextBatchAsync() + { + _mockHttp + .When($"{_baseUrl}/items-feed") + .WithQueryString("system.type=article") + .Respond("application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}DeliveryClient{Path.DirectorySeparatorChar}articles_feed.json"))); + + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + var feed = client.GetItemsFeed(new EqualsFilter("system.type", "article")); + var items = new List(); + var timesCalled = 0; + while (feed.HasMoreResults) + { + timesCalled++; + var response = await feed.FetchNextBatchAsync(); + items.AddRange(response); + } + + Assert.Equal(6, items.Count); + Assert.Equal(1, timesCalled); + } + + [Fact] + public async void GetItemsFeed_MultipleBatches_FetchNextBatchAsync() + { + // Second batch + _mockHttp + .When($"{_baseUrl}/items-feed") + .WithQueryString("system.type=article") + .WithHeaders("X-Continuation", "token") + .Respond("application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}DeliveryClient{Path.DirectorySeparatorChar}articles_feed_2.json"))); + + // First batch + _mockHttp + .When($"{_baseUrl}/items-feed") + .WithQueryString("system.type=article") + .Respond(new[] { new KeyValuePair("X-Continuation", "token"), }, "application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}DeliveryClient{Path.DirectorySeparatorChar}articles_feed_1.json"))); + + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + var feed = client.GetItemsFeed(new EqualsFilter("system.type", "article")); + var items = new List(); + var timesCalled = 0; + while (feed.HasMoreResults) + { + timesCalled++; + var response = await feed.FetchNextBatchAsync(); + items.AddRange(response); + } + + Assert.Equal(6, items.Count); + Assert.Equal(2, timesCalled); + } + [Fact] public async void GetTypeAsync() { @@ -534,6 +614,92 @@ public void GetStronglyTypedItemsResponse() Assert.True(items.All(i => i.GetType() == typeof(ContentItemModelWithAttributes))); } + [Fact] + public void GetStronglyTypedItemsFeed_DepthParameter_ThrowsArgumentException() + { + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + Assert.Throws(() => client.GetItemsFeed(new DepthParameter(2))); + } + + [Fact] + public void GetStronglyTypedItemsFeed_LimitParameter_ThrowsArgumentException() + { + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + Assert.Throws(() => client.GetItemsFeed(new LimitParameter(2))); + } + + [Fact] + public void GetStronglyTypedItemsFeed_SkipParameter_ThrowsArgumentException() + { + var client = InitializeDeliveryClientWithACustomTypeProvider(_mockHttp); + + Assert.Throws(() => client.GetItemsFeed(new SkipParameter(2))); + } + + [Fact] + public async void GetStronglyTypedItemsFeed_SingleBatch_FetchNextBatchAsync() + { + _mockHttp + .When($"{_baseUrl}/items-feed") + .WithQueryString("system.type=article&elements=title,summary,personas") + .Respond("application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}DeliveryClient{Path.DirectorySeparatorChar}articles_feed.json"))); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + A.CallTo(() => _mockTypeProvider.GetType("article")) + .ReturnsLazily(() => typeof(ArticlePartialItemModel)); + + var feed = client.GetItemsFeed(new EqualsFilter("system.type", "article"), new ElementsParameter("title", "summary", "personas")); + var items = new List(); + var timesCalled = 0; + while (feed.HasMoreResults) + { + timesCalled++; + var response = await feed.FetchNextBatchAsync(); + items.AddRange(response); + } + + Assert.Equal(6, items.Count); + Assert.Equal(1, timesCalled); + Assert.True(items.All(i => i.GetType() == typeof(ArticlePartialItemModel))); + } + + [Fact] + public async void GetStronglyTypedItemsFeed_MultipleBatches_FetchNextBatchAsync() + { + // Second batch + _mockHttp + .When($"{_baseUrl}/items-feed") + .WithQueryString("system.type=article&elements=title,summary,personas") + .WithHeaders("X-Continuation", "token") + .Respond("application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}DeliveryClient{Path.DirectorySeparatorChar}articles_feed_2.json"))); + + // First batch + _mockHttp + .When($"{_baseUrl}/items-feed") + .WithQueryString("system.type=article&elements=title,summary,personas") + .Respond(new[] { new KeyValuePair("X-Continuation", "token"), }, "application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}DeliveryClient{Path.DirectorySeparatorChar}articles_feed_1.json"))); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + A.CallTo(() => _mockTypeProvider.GetType("article")) + .ReturnsLazily(() => typeof(ArticlePartialItemModel)); + + var feed = client.GetItemsFeed(new EqualsFilter("system.type", "article"), new ElementsParameter("title", "summary", "personas")); + var items = new List(); + var timesCalled = 0; + while (feed.HasMoreResults) + { + timesCalled++; + var response = await feed.FetchNextBatchAsync(); + items.AddRange(response); + } + + Assert.Equal(6, items.Count); + Assert.Equal(2, timesCalled); + Assert.True(items.All(i => i.GetType() == typeof(ArticlePartialItemModel))); + } + [Fact] public void CastResponse() { @@ -567,6 +733,24 @@ public void CastListingResponse() Assert.True(stronglyTypedListingResponse.Items.Any()); } + [Fact] + public async void CastItemsFeedResponse() + { + _mockHttp + .When($"{_baseUrl}/items-feed") + .WithQueryString("system.type=article") + .Respond("application/json", File.ReadAllText(Path.Combine(Environment.CurrentDirectory, $"Fixtures{Path.DirectorySeparatorChar}DeliveryClient{Path.DirectorySeparatorChar}articles_feed.json"))); + + var client = InitializeDeliveryClientWithCustomModelProvider(_mockHttp); + + var feed = client.GetItemsFeed(new EqualsFilter("system.type", "article")); + var response = await feed.FetchNextBatchAsync(); + var items = response.CastTo(); + + Assert.NotNull(items); + Assert.Equal(6, items.Count()); + } + [Fact] public void CastContentItem() { diff --git a/Kentico.Kontent.Delivery.Tests/Fixtures/DeliveryClient/articles_feed.json b/Kentico.Kontent.Delivery.Tests/Fixtures/DeliveryClient/articles_feed.json new file mode 100644 index 00000000..9ceb795f --- /dev/null +++ b/Kentico.Kontent.Delivery.Tests/Fixtures/DeliveryClient/articles_feed.json @@ -0,0 +1,229 @@ +{ + "items": [ + { + "system": { + "id": "cf106f4e-30a4-42ef-b313-b8ea3fd3e5c5", + "name": "Coffee Beverages Explained", + "codename": "coffee_beverages_explained", + "language": "en-US", + "type": "article", + "sitemap_locations": [], + "last_modified": "2019-03-27T13:12:58.578Z" + }, + "elements": { + "title": { + "type": "text", + "name": "Title", + "value": "Coffee Beverages Explained" + }, + "summary": { + "type": "text", + "name": "Summary", + "value": "Espresso and filtered coffee are the two main categories of coffee, based on the method of preparation. Learn about individual types of coffee that fall under these categories." + }, + "personas": { + "type": "taxonomy", + "name": "Personas", + "taxonomy_group": "personas", + "value": [ + { + "name": "Coffee lover", + "codename": "coffee_lover" + } + ] + } + } + }, + { + "system": { + "id": "117cdfae-52cf-4885-b271-66aef6825612", + "name": "Coffee processing techniques", + "codename": "coffee_processing_techniques", + "language": "en-US", + "type": "article", + "sitemap_locations": [], + "last_modified": "2019-03-27T13:13:35.312Z" + }, + "elements": { + "title": { + "type": "text", + "name": "Title", + "value": "Coffee processing techniques" + }, + "summary": { + "type": "text", + "name": "Summary", + "value": "Learn about the techniques of processing the products of coffee plants. Different methods are used in different parts of the world depending mainly on their weather conditions." + }, + "personas": { + "type": "taxonomy", + "name": "Personas", + "taxonomy_group": "personas", + "value": [ + { + "name": "Coffee blogger", + "codename": "coffee_blogger" + }, + { + "name": "Coffee lover", + "codename": "coffee_lover" + } + ] + } + } + }, + { + "system": { + "id": "23f71096-fa89-4f59-a3f9-970e970944ec", + "name": "Donate with us", + "codename": "donate_with_us", + "language": "en-US", + "type": "article", + "sitemap_locations": [], + "last_modified": "2019-03-27T13:14:07.384Z" + }, + "elements": { + "title": { + "type": "text", + "name": "Title", + "value": "Donate with us" + }, + "summary": { + "type": "text", + "name": "Summary", + "value": "Dancing Goat regularly donates money to Children in Africa, a foundation helping children with food, accommodation, education, and other essentials. Donate with us and create a better world." + }, + "personas": { + "type": "taxonomy", + "name": "Personas", + "taxonomy_group": "personas", + "value": [ + { + "name": "Cafe owner", + "codename": "cafe_owner" + } + ] + } + } + }, + { + "system": { + "id": "f4b3fc05-e988-4dae-9ac1-a94aba566474", + "name": "On Roasts", + "codename": "on_roasts", + "language": "en-US", + "type": "article", + "sitemap_locations": [], + "last_modified": "2019-03-27T13:21:11.38Z" + }, + "elements": { + "title": { + "type": "text", + "name": "Title", + "value": "On Roasts" + }, + "summary": { + "type": "text", + "name": "Summary", + "value": "Roasting coffee beans can take from 6 to 13 minutes. Different roasting times produce different types of coffee, with varying concentration of caffeine and intensity of the original flavor." + }, + "personas": { + "type": "taxonomy", + "name": "Personas", + "taxonomy_group": "personas", + "value": [ + { + "name": "Barista", + "codename": "barista" + }, + { + "name": "Coffee blogger", + "codename": "coffee_blogger" + } + ] + } + } + }, + { + "system": { + "id": "b2fea94c-73fd-42ec-a22f-f409878de187", + "name": "Origins of Arabica Bourbon", + "codename": "origins_of_arabica_bourbon", + "language": "en-US", + "type": "article", + "sitemap_locations": [], + "last_modified": "2019-03-27T13:21:49.151Z" + }, + "elements": { + "title": { + "type": "text", + "name": "Title", + "value": "Origins of Arabica Bourbon" + }, + "summary": { + "type": "text", + "name": "Summary", + "value": "This one particular type of coffee, the Arabica Bourbon, is now sold only in Japan. It has been brought back to life by enthusiasts after being almost forgotten for nearly sixty years." + }, + "personas": { + "type": "taxonomy", + "name": "Personas", + "taxonomy_group": "personas", + "value": [ + { + "name": "Barista", + "codename": "barista" + }, + { + "name": "Coffee blogger", + "codename": "coffee_blogger" + } + ] + } + } + }, + { + "system": { + "id": "3120ec15-a4a2-47ec-8ccd-c85ac8ac5ba5", + "name": "Which brewing fits you?", + "codename": "which_brewing_fits_you_", + "language": "en-US", + "type": "article", + "sitemap_locations": [], + "last_modified": "2019-03-27T13:24:54.042Z" + }, + "elements": { + "title": { + "type": "text", + "name": "Title", + "value": "Which brewing fits you?" + }, + "summary": { + "type": "text", + "name": "Summary", + "value": "We have put down three procedures with clearly written steps describing the process of making coffee. Read this article to convince yourself that brewing coffee is no science" + }, + "personas": { + "type": "taxonomy", + "name": "Personas", + "taxonomy_group": "personas", + "value": [ + { + "name": "Coffee lover", + "codename": "coffee_lover" + }, + { + "name": "Coffee blogger", + "codename": "coffee_blogger" + }, + { + "name": "Barista", + "codename": "barista" + } + ] + } + } + } + ], + "modular_content": {} +} \ No newline at end of file diff --git a/Kentico.Kontent.Delivery.Tests/Fixtures/DeliveryClient/articles_feed_1.json b/Kentico.Kontent.Delivery.Tests/Fixtures/DeliveryClient/articles_feed_1.json new file mode 100644 index 00000000..dbfcfc65 --- /dev/null +++ b/Kentico.Kontent.Delivery.Tests/Fixtures/DeliveryClient/articles_feed_1.json @@ -0,0 +1,187 @@ +{ + "items": [ + { + "system": { + "id": "cf106f4e-30a4-42ef-b313-b8ea3fd3e5c5", + "name": "Coffee Beverages Explained", + "codename": "coffee_beverages_explained", + "language": "en-US", + "type": "article", + "sitemap_locations": [], + "last_modified": "2019-03-27T13:12:58.578Z" + }, + "elements": { + "title": { + "type": "text", + "name": "Title", + "value": "Coffee Beverages Explained" + }, + "summary": { + "type": "text", + "name": "Summary", + "value": "Espresso and filtered coffee are the two main categories of coffee, based on the method of preparation. Learn about individual types of coffee that fall under these categories." + }, + "personas": { + "type": "taxonomy", + "name": "Personas", + "taxonomy_group": "personas", + "value": [ + { + "name": "Coffee lover", + "codename": "coffee_lover" + } + ] + } + } + }, + { + "system": { + "id": "117cdfae-52cf-4885-b271-66aef6825612", + "name": "Coffee processing techniques", + "codename": "coffee_processing_techniques", + "language": "en-US", + "type": "article", + "sitemap_locations": [], + "last_modified": "2019-03-27T13:13:35.312Z" + }, + "elements": { + "title": { + "type": "text", + "name": "Title", + "value": "Coffee processing techniques" + }, + "summary": { + "type": "text", + "name": "Summary", + "value": "Learn about the techniques of processing the products of coffee plants. Different methods are used in different parts of the world depending mainly on their weather conditions." + }, + "personas": { + "type": "taxonomy", + "name": "Personas", + "taxonomy_group": "personas", + "value": [ + { + "name": "Coffee blogger", + "codename": "coffee_blogger" + }, + { + "name": "Coffee lover", + "codename": "coffee_lover" + } + ] + } + } + }, + { + "system": { + "id": "23f71096-fa89-4f59-a3f9-970e970944ec", + "name": "Donate with us", + "codename": "donate_with_us", + "language": "en-US", + "type": "article", + "sitemap_locations": [], + "last_modified": "2019-03-27T13:14:07.384Z" + }, + "elements": { + "title": { + "type": "text", + "name": "Title", + "value": "Donate with us" + }, + "summary": { + "type": "text", + "name": "Summary", + "value": "Dancing Goat regularly donates money to Children in Africa, a foundation helping children with food, accommodation, education, and other essentials. Donate with us and create a better world." + }, + "personas": { + "type": "taxonomy", + "name": "Personas", + "taxonomy_group": "personas", + "value": [ + { + "name": "Cafe owner", + "codename": "cafe_owner" + } + ] + } + } + }, + { + "system": { + "id": "f4b3fc05-e988-4dae-9ac1-a94aba566474", + "name": "On Roasts", + "codename": "on_roasts", + "language": "en-US", + "type": "article", + "sitemap_locations": [], + "last_modified": "2019-03-27T13:21:11.38Z" + }, + "elements": { + "title": { + "type": "text", + "name": "Title", + "value": "On Roasts" + }, + "summary": { + "type": "text", + "name": "Summary", + "value": "Roasting coffee beans can take from 6 to 13 minutes. Different roasting times produce different types of coffee, with varying concentration of caffeine and intensity of the original flavor." + }, + "personas": { + "type": "taxonomy", + "name": "Personas", + "taxonomy_group": "personas", + "value": [ + { + "name": "Barista", + "codename": "barista" + }, + { + "name": "Coffee blogger", + "codename": "coffee_blogger" + } + ] + } + } + }, + { + "system": { + "id": "b2fea94c-73fd-42ec-a22f-f409878de187", + "name": "Origins of Arabica Bourbon", + "codename": "origins_of_arabica_bourbon", + "language": "en-US", + "type": "article", + "sitemap_locations": [], + "last_modified": "2019-03-27T13:21:49.151Z" + }, + "elements": { + "title": { + "type": "text", + "name": "Title", + "value": "Origins of Arabica Bourbon" + }, + "summary": { + "type": "text", + "name": "Summary", + "value": "This one particular type of coffee, the Arabica Bourbon, is now sold only in Japan. It has been brought back to life by enthusiasts after being almost forgotten for nearly sixty years." + }, + "personas": { + "type": "taxonomy", + "name": "Personas", + "taxonomy_group": "personas", + "value": [ + { + "name": "Barista", + "codename": "barista" + }, + { + "name": "Coffee blogger", + "codename": "coffee_blogger" + } + ] + } + } + } + ], + "modular_content": {} +} \ No newline at end of file diff --git a/Kentico.Kontent.Delivery.Tests/Fixtures/DeliveryClient/articles_feed_2.json b/Kentico.Kontent.Delivery.Tests/Fixtures/DeliveryClient/articles_feed_2.json new file mode 100644 index 00000000..437fe2f6 --- /dev/null +++ b/Kentico.Kontent.Delivery.Tests/Fixtures/DeliveryClient/articles_feed_2.json @@ -0,0 +1,47 @@ +{ + "items": [ + { + "system": { + "id": "3120ec15-a4a2-47ec-8ccd-c85ac8ac5ba5", + "name": "Which brewing fits you?", + "codename": "which_brewing_fits_you_", + "language": "en-US", + "type": "article", + "sitemap_locations": [], + "last_modified": "2019-03-27T13:24:54.042Z" + }, + "elements": { + "title": { + "type": "text", + "name": "Title", + "value": "Which brewing fits you?" + }, + "summary": { + "type": "text", + "name": "Summary", + "value": "We have put down three procedures with clearly written steps describing the process of making coffee. Read this article to convince yourself that brewing coffee is no science" + }, + "personas": { + "type": "taxonomy", + "name": "Personas", + "taxonomy_group": "personas", + "value": [ + { + "name": "Coffee lover", + "codename": "coffee_lover" + }, + { + "name": "Coffee blogger", + "codename": "coffee_blogger" + }, + { + "name": "Barista", + "codename": "barista" + } + ] + } + } + } + ], + "modular_content": {} +} \ No newline at end of file diff --git a/Kentico.Kontent.Delivery.Tests/Kentico.Kontent.Delivery.Tests.csproj b/Kentico.Kontent.Delivery.Tests/Kentico.Kontent.Delivery.Tests.csproj index 2c515314..d6171b33 100644 --- a/Kentico.Kontent.Delivery.Tests/Kentico.Kontent.Delivery.Tests.csproj +++ b/Kentico.Kontent.Delivery.Tests/Kentico.Kontent.Delivery.Tests.csproj @@ -58,6 +58,27 @@ + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + Always diff --git a/Kentico.Kontent.Delivery.Tests/Models/ArticlePartialItemModel.cs b/Kentico.Kontent.Delivery.Tests/Models/ArticlePartialItemModel.cs new file mode 100644 index 00000000..194b9c33 --- /dev/null +++ b/Kentico.Kontent.Delivery.Tests/Models/ArticlePartialItemModel.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace Kentico.Kontent.Delivery.Tests +{ + public class ArticlePartialItemModel + { + public string Title { get; set; } + public string Summary { get; set; } + public IEnumerable Personas { get; set; } + public ContentItemSystemAttributes System { get; set; } + } +} \ No newline at end of file diff --git a/Kentico.Kontent.Delivery/DeliveryClient.cs b/Kentico.Kontent.Delivery/DeliveryClient.cs index 91bd72d2..13ebfe3b 100644 --- a/Kentico.Kontent.Delivery/DeliveryClient.cs +++ b/Kentico.Kontent.Delivery/DeliveryClient.cs @@ -28,7 +28,7 @@ internal sealed class DeliveryClient : IDeliveryClient private DeliveryEndpointUrlBuilder _urlBuilder; - private DeliveryEndpointUrlBuilder UrlBuilder + private DeliveryEndpointUrlBuilder UrlBuilder => _urlBuilder ?? (_urlBuilder = new DeliveryEndpointUrlBuilder(DeliveryOptions)); /// @@ -214,6 +214,65 @@ public async Task> GetItemsAsync(IEnumerable(response, ModelProvider); } + /// + /// Returns a feed that is used to traverse through content items matching the optional filtering parameters. + /// + /// An array of query parameters, for example, for filtering or ordering. + /// The instance that can be used to enumerate through content items. If no query parameters are specified, all content items are enumerated. + public DeliveryItemsFeed GetItemsFeed(params IQueryParameter[] parameters) + { + return GetItemsFeed((IEnumerable) parameters); + } + + /// + /// Returns a feed that is used to traverse through content items matching the optional filtering parameters. + /// + /// A collection of query parameters, for example, for filtering or ordering. + /// The instance that can be used to enumerate through content items. If no query parameters are specified, all content items are enumerated. + public DeliveryItemsFeed GetItemsFeed(IEnumerable parameters) + { + ValidateItemsFeedParameters(parameters); + var endpointUrl = UrlBuilder.GetItemsFeedUrl(parameters); + return new DeliveryItemsFeed(GetItemsBatchAsync, endpointUrl); + + async Task GetItemsBatchAsync(string continuationToken) + { + var response = await GetDeliverResponseAsync(endpointUrl, continuationToken); + return new DeliveryItemsFeedResponse(response, ModelProvider, ContentLinkUrlResolver); + } + } + + /// + /// Returns a feed that is used to traverse through strongly typed content items matching the optional filtering parameters. + /// + /// Type of the model. (Or if the return type is not yet known.) + /// An array of query parameters, for example, for filtering or ordering. + /// The instance that can be used to enumerate through content items. If no query parameters are specified, all content items are enumerated. + public DeliveryItemsFeed GetItemsFeed(params IQueryParameter[] parameters) + { + return GetItemsFeed((IEnumerable) parameters); + } + + /// + /// Returns a feed that is used to traverse through strongly typed content items matching the optional filtering parameters. + /// + /// Type of the model. (Or if the return type is not yet known.) + /// A collection of query parameters, for example, for filtering or ordering. + /// The instance that can be used to enumerate through content items. If no query parameters are specified, all content items are enumerated. + public DeliveryItemsFeed GetItemsFeed(IEnumerable parameters) + { + var enhancedParameters = ExtractParameters(parameters).ToList(); + ValidateItemsFeedParameters(enhancedParameters); + var endpointUrl = UrlBuilder.GetItemsFeedUrl(enhancedParameters); + return new DeliveryItemsFeed(GetItemsBatchAsync, endpointUrl); + + async Task> GetItemsBatchAsync(string continuationToken) + { + var response = await GetDeliverResponseAsync(endpointUrl, continuationToken); + return new DeliveryItemsFeedResponse(response, ModelProvider); + } + } + /// /// Returns a content type as JSON data. /// @@ -408,7 +467,7 @@ public async Task GetTaxonomiesAsync(IEnumerabl return new DeliveryTaxonomyListingResponse(response); } - private async Task GetDeliverResponseAsync(string endpointUrl) + private async Task GetDeliverResponseAsync(string endpointUrl, string continuationToken = null) { if (DeliveryOptions.UsePreviewApi && DeliveryOptions.UseSecuredProductionApi) { @@ -420,7 +479,7 @@ private async Task GetDeliverResponseAsync(string endpointUrl) // Use the resilience logic. var policyResult = await ResiliencePolicyProvider?.Policy?.ExecuteAndCaptureAsync(() => { - return SendHttpMessage(endpointUrl); + return SendHttpMessage(endpointUrl, continuationToken); } ); @@ -428,10 +487,10 @@ private async Task GetDeliverResponseAsync(string endpointUrl) } // Omit using the resilience logic completely. - return await GetResponseContent(await SendHttpMessage(endpointUrl)); + return await GetResponseContent(await SendHttpMessage(endpointUrl, continuationToken)); } - private Task SendHttpMessage(string endpointUrl) + private Task SendHttpMessage(string endpointUrl, string continuationToken = null) { var message = new HttpRequestMessage(HttpMethod.Get, endpointUrl); @@ -452,6 +511,11 @@ private Task SendHttpMessage(string endpointUrl) message.Headers.AddAuthorizationHeader("Bearer", DeliveryOptions.PreviewApiKey); } + if (continuationToken != null) + { + message.Headers.AddContinuationHeader(continuationToken); + } + return HttpClient.SendAsync(message); } @@ -471,8 +535,9 @@ private async Task GetResponseContent(HttpResponseMessage httpRespo { var content = JObject.Parse(await httpResponseMessage.Content?.ReadAsStringAsync()); var hasStaleContent = HasStaleContent(httpResponseMessage); + var continuationToken = httpResponseMessage.Headers.GetContinuationHeader(); - return new ApiResponse(content, hasStaleContent: hasStaleContent, requestUrl: httpResponseMessage.RequestMessage.RequestUri.AbsoluteUri); + return new ApiResponse(content, hasStaleContent, continuationToken, httpResponseMessage.RequestMessage.RequestUri.AbsoluteUri); } string faultContent = null; @@ -515,5 +580,24 @@ private static bool IsTypeInQueryParameters(IEnumerable paramet .Equals("system.type", StringComparison.Ordinal)); return typeFilterExists ?? false; } + + private static void ValidateItemsFeedParameters(IEnumerable parameters) + { + var parameterList = parameters.ToList(); + if (parameterList.Any(x => x is DepthParameter)) + { + throw new ArgumentException("Depth parameter is not supported in items feed."); + } + + if (parameterList.Any(x => x is LimitParameter)) + { + throw new ArgumentException("Limit parameter is not supported in items feed."); + } + + if (parameterList.Any(x => x is SkipParameter)) + { + throw new ArgumentException("Skip parameter is not supported in items feed."); + } + } } } diff --git a/Kentico.Kontent.Delivery/DeliveryEndpointUrlBuilder.cs b/Kentico.Kontent.Delivery/DeliveryEndpointUrlBuilder.cs index 6bf52e0b..42b2ae96 100644 --- a/Kentico.Kontent.Delivery/DeliveryEndpointUrlBuilder.cs +++ b/Kentico.Kontent.Delivery/DeliveryEndpointUrlBuilder.cs @@ -9,6 +9,7 @@ internal sealed class DeliveryEndpointUrlBuilder private const int UrlMaxLength = 65519; private const string UrlTemplateItem = "/items/{0}"; private const string UrlTemplateItems = "/items"; + private const string UrlTemplateItemsFeed = "/items-feed"; private const string UrlTemplateType = "/types/{0}"; private const string UrlTemplateTypes = "/types"; private const string UrlTemplateElement = "/types/{0}/elements/{1}"; @@ -42,6 +43,16 @@ public string GetItemsUrl(IEnumerable parameters) return GetUrl(UrlTemplateItems, parameters); } + public string GetItemsFeedUrl(string[] parameters) + { + return GetUrl(UrlTemplateItemsFeed, parameters); + } + + public string GetItemsFeedUrl(IEnumerable parameters) + { + return GetUrl(UrlTemplateItemsFeed, parameters); + } + public string GetTypeUrl(string codename) { return GetUrl(string.Format(UrlTemplateType, Uri.EscapeDataString(codename))); diff --git a/Kentico.Kontent.Delivery/Extensions/HttpRequestHeadersExtensions.cs b/Kentico.Kontent.Delivery/Extensions/HttpRequestHeadersExtensions.cs index 70e8a06d..48d2b8c1 100644 --- a/Kentico.Kontent.Delivery/Extensions/HttpRequestHeadersExtensions.cs +++ b/Kentico.Kontent.Delivery/Extensions/HttpRequestHeadersExtensions.cs @@ -9,6 +9,7 @@ internal static class HttpRequestHeadersExtensions { private const string SdkTrackingHeaderName = "X-KC-SDKID"; private const string WaitForLoadingNewContentHeaderName = "X-KC-Wait-For-Loading-New-Content"; + private const string ContinuationHeaderName = "X-Continuation"; private const string PackageRepositoryHost = "nuget.org"; @@ -31,6 +32,11 @@ internal static void AddAuthorizationHeader(this HttpRequestHeaders header, stri header.Authorization = new AuthenticationHeaderValue(scheme, parameter); } + internal static void AddContinuationHeader(this HttpRequestHeaders header, string continuation) + { + header.Add(ContinuationHeaderName, continuation); + } + private static string GetSdkVersion() { var assembly = Assembly.GetExecutingAssembly(); diff --git a/Kentico.Kontent.Delivery/Extensions/HttpResponseHeadersExtensions.cs b/Kentico.Kontent.Delivery/Extensions/HttpResponseHeadersExtensions.cs new file mode 100644 index 00000000..b398208d --- /dev/null +++ b/Kentico.Kontent.Delivery/Extensions/HttpResponseHeadersExtensions.cs @@ -0,0 +1,17 @@ +using System.Linq; +using System.Net.Http.Headers; + +namespace Kentico.Kontent.Delivery.Extensions +{ + internal static class HttpResponseHeadersExtensions + { + private const string ContinuationHeaderName = "X-Continuation"; + + internal static string GetContinuationHeader(this HttpResponseHeaders headers) + { + return headers.TryGetValues(ContinuationHeaderName, out var headerValues) + ? headerValues.FirstOrDefault() + : null; + } + } +} diff --git a/Kentico.Kontent.Delivery/IDeliveryClient.cs b/Kentico.Kontent.Delivery/IDeliveryClient.cs index 0656c3a8..2016786c 100644 --- a/Kentico.Kontent.Delivery/IDeliveryClient.cs +++ b/Kentico.Kontent.Delivery/IDeliveryClient.cs @@ -88,6 +88,36 @@ public interface IDeliveryClient /// The instance that contains the content items. If no query parameters are specified, all content items are returned. Task> GetItemsAsync(IEnumerable parameters); + /// + /// Returns a feed that is used to traverse through content items matching the optional filtering parameters. + /// + /// A collection of query parameters, for example, for filtering or ordering. + /// The instance that can be used to enumerate through content items. If no query parameters are specified, all content items are enumerated. + DeliveryItemsFeed GetItemsFeed(params IQueryParameter[] parameters); + + /// + /// Returns a feed that is used to traverse through content items matching the optional filtering parameters. + /// + /// A collection of query parameters, for example, for filtering or ordering. + /// The instance that can be used to enumerate through content items. If no query parameters are specified, all content items are enumerated. + DeliveryItemsFeed GetItemsFeed(IEnumerable parameters); + + /// + /// Returns a feed that is used to traverse through strongly typed content items matching the optional filtering parameters. + /// + /// Type of the model. (Or if the return type is not yet known.) + /// A collection of query parameters, for example, for filtering or ordering. + /// The instance that can be used to enumerate through content items. If no query parameters are specified, all content items are enumerated. + DeliveryItemsFeed GetItemsFeed(params IQueryParameter[] parameters); + + /// + /// Returns a feed that is used to traverse through strongly typed content items matching the optional filtering parameters. + /// + /// Type of the model. (Or if the return type is not yet known.) + /// A collection of query parameters, for example, for filtering or ordering. + /// The instance that can be used to enumerate through content items. If no query parameters are specified, all content items are enumerated. + DeliveryItemsFeed GetItemsFeed(IEnumerable parameters); + /// /// Returns a content type as JSON data. /// diff --git a/Kentico.Kontent.Delivery/Responses/ApiResponse.cs b/Kentico.Kontent.Delivery/Responses/ApiResponse.cs index 3b8014c8..39211238 100644 --- a/Kentico.Kontent.Delivery/Responses/ApiResponse.cs +++ b/Kentico.Kontent.Delivery/Responses/ApiResponse.cs @@ -19,6 +19,11 @@ public sealed class ApiResponse /// public bool HasStaleContent { get; } + /// + /// Gets the continuation token to be used for continuing enumeration of the Kentico Kontent Delivery API. + /// + public string ContinuationToken { get; } + /// /// Gets the URL used to retrieve this response for debugging purposes. /// @@ -29,11 +34,13 @@ public sealed class ApiResponse /// /// JSON content. /// Specifies whether content is stale. - /// The URL used to retrieve this response. - internal ApiResponse(JObject content, bool hasStaleContent, string requestUrl) + /// Continuation token to be used for continuing enumeration. + /// URL used to retrieve this response. + internal ApiResponse(JObject content, bool hasStaleContent, string continuationToken, string requestUrl) { Content = content; HasStaleContent = hasStaleContent; + ContinuationToken = continuationToken; RequestUrl = requestUrl; } } diff --git a/Kentico.Kontent.Delivery/Responses/DeliveryItemsFeed.cs b/Kentico.Kontent.Delivery/Responses/DeliveryItemsFeed.cs new file mode 100644 index 00000000..1949fb18 --- /dev/null +++ b/Kentico.Kontent.Delivery/Responses/DeliveryItemsFeed.cs @@ -0,0 +1,50 @@ +using System.Threading.Tasks; + +namespace Kentico.Kontent.Delivery +{ + /// + /// Represents a feed that can be used to retrieve content items from Kentico Kontent Delivery API in smaller batches. + /// + public class DeliveryItemsFeed + { + internal delegate Task GetFeedResponse(string continuationToken); + + private DeliveryItemsFeedResponse _lastResponse; + private readonly GetFeedResponse _getFeedResponseAsync; + + /// + /// Indicates whether there are more batches to fetch. + /// + public bool HasMoreResults => _lastResponse == null || !string.IsNullOrEmpty(_lastResponse.ContinuationToken); + + /// + /// Gets the URL used to retrieve responses in this feed for debugging purposes. + /// + public string ApiUrl { get; } + + /// + /// Initializes a new instance of class. + /// + /// Function to retrieve next batch of content items. + /// URL used to retrieve responses for this feed. + internal DeliveryItemsFeed(GetFeedResponse getFeedResponseAsync, string requestUrl) + { + _getFeedResponseAsync = getFeedResponseAsync; + ApiUrl = requestUrl; + } + + /// + /// Retrieves the next feed batch if available. + /// + /// Instance of class that contains a list of content items. + public async Task FetchNextBatchAsync() + { + if (HasMoreResults) + { + _lastResponse = await _getFeedResponseAsync(_lastResponse?.ContinuationToken); + } + + return _lastResponse; + } + } +} \ No newline at end of file diff --git a/Kentico.Kontent.Delivery/Responses/DeliveryItemsFeedResponse.cs b/Kentico.Kontent.Delivery/Responses/DeliveryItemsFeedResponse.cs new file mode 100644 index 00000000..e995a0df --- /dev/null +++ b/Kentico.Kontent.Delivery/Responses/DeliveryItemsFeedResponse.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Newtonsoft.Json.Linq; + +namespace Kentico.Kontent.Delivery +{ + /// + /// Represents a partial response from Kentico Kontent Delivery API enumeration methods that contains a list of content items. + /// + public class DeliveryItemsFeedResponse : FeedResponse, IEnumerable + { + private readonly IModelProvider _modelProvider; + private readonly Lazy> _items; + private readonly Lazy _linkedItems; + + /// + /// Gets a read-only list of content items. + /// + public IReadOnlyList Items => _items.Value; + + /// + /// Gets the dynamic view of the JSON response where linked items and their properties can be retrieved by name, for example LinkedItems.about_us.elements.description.value. + /// + public dynamic LinkedItems => _linkedItems.Value; + + /// + /// Initializes a new instance of the class. + /// + /// The response from Kentico Kontent Delivery API that contains a list of content items. + /// The provider that can convert JSON responses into instances of .NET types. + /// The resolver that can generate URLs for links in rich text elements. + internal DeliveryItemsFeedResponse(ApiResponse response, IModelProvider modelProvider, IContentLinkUrlResolver contentLinkUrlResolver) : base(response) + { + _modelProvider = modelProvider; + _items = new Lazy>(() => ((JArray)_response.Content["items"]) + .Select(source => new ContentItem(source, _response.Content["modular_content"], contentLinkUrlResolver, _modelProvider)) + .ToList().AsReadOnly(), LazyThreadSafetyMode.PublicationOnly); + _linkedItems = new Lazy(() => (JObject)_response.Content["modular_content"].DeepClone(), LazyThreadSafetyMode.PublicationOnly); + } + + /// + /// Returns an enumerator that iterates through the collection of content items. + /// + public IEnumerator GetEnumerator() + { + return Items.GetEnumerator(); + } + + /// + /// Returns an enumerator that iterates through the collection of content items. + /// + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// + /// Casts this response to a generic one. To succeed all items must be of the same type. + /// + /// The object type that the items will be deserialized to. + public DeliveryItemsFeedResponse CastTo() + { + return new DeliveryItemsFeedResponse(_response, _modelProvider); + } + } +} \ No newline at end of file diff --git a/Kentico.Kontent.Delivery/Responses/FeedResponse.cs b/Kentico.Kontent.Delivery/Responses/FeedResponse.cs new file mode 100644 index 00000000..4705b74b --- /dev/null +++ b/Kentico.Kontent.Delivery/Responses/FeedResponse.cs @@ -0,0 +1,21 @@ +namespace Kentico.Kontent.Delivery +{ + /// + /// Represents a successful response from Kentico Kontent Delivery API enumeration. + /// + public abstract class FeedResponse : AbstractResponse + { + /// + /// Gets the continuation token to be used for continuing enumeration of the Kentico Kontent Delivery API. + /// + internal string ContinuationToken => _response.ContinuationToken; + + /// + /// Initializes a new instance of the class. + /// + /// A successful JSON response from Kentico Kontent Delivery API. + protected FeedResponse(ApiResponse apiResponse) : base(apiResponse) + { + } + } +} \ No newline at end of file diff --git a/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemsFeed.cs b/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemsFeed.cs new file mode 100644 index 00000000..401fe628 --- /dev/null +++ b/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemsFeed.cs @@ -0,0 +1,51 @@ +using System.Threading.Tasks; + +namespace Kentico.Kontent.Delivery +{ + /// + /// Represents a feed that can be used to retrieve strongly typed content items from Kentico Kontent Delivery API in smaller batches. + /// + /// The type of content items in the feed. + public class DeliveryItemsFeed + { + internal delegate Task> GetFeedResponse(string continuationToken); + + private DeliveryItemsFeedResponse _lastResponse; + private readonly GetFeedResponse _getFeedResponseAsync; + + /// + /// Indicates whether there are more batches to fetch. + /// + public bool HasMoreResults => _lastResponse == null || !string.IsNullOrEmpty(_lastResponse.ContinuationToken); + + /// + /// Gets the URL used to retrieve responses in this feed for debugging purposes. + /// + public string ApiUrl { get; } + + /// + /// Initializes a new instance of class. + /// + /// Function to retrieve next batch of content items. + /// URL used to retrieve responses for this feed. + internal DeliveryItemsFeed(GetFeedResponse getFeedResponseAsync, string requestUrl) + { + _getFeedResponseAsync = getFeedResponseAsync; + ApiUrl = requestUrl; + } + + /// + /// Retrieves the next feed batch if available. + /// + /// Instance of class that contains a list of strongly typed content items. + public async Task> FetchNextBatchAsync() + { + if (HasMoreResults) + { + _lastResponse = await _getFeedResponseAsync(_lastResponse?.ContinuationToken); + } + + return _lastResponse; + } + } +} \ No newline at end of file diff --git a/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemsFeedResponse.cs b/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemsFeedResponse.cs new file mode 100644 index 00000000..90e7fdfa --- /dev/null +++ b/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemsFeedResponse.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Newtonsoft.Json.Linq; + +namespace Kentico.Kontent.Delivery +{ + /// + /// Represents a partial response from Kentico Kontent Delivery API enumeration methods that contains a list of content items. + /// + /// The type of content items in the response. + public class DeliveryItemsFeedResponse : FeedResponse, IEnumerable + { + private readonly Lazy> _items; + private readonly Lazy _linkedItems; + + /// + /// Gets a read-only list of content items. + /// + public IReadOnlyList Items => _items.Value; + + /// + /// Gets the dynamic view of the JSON response where linked items and their properties can be retrieved by name, for example LinkedItems.about_us.elements.description.value. + /// + public dynamic LinkedItems => _linkedItems.Value; + + /// + /// Initializes a new instance of the class. + /// + /// The response from Kentico Kontent Delivery API that contains a list of content items. + /// The provider that can convert JSON responses into instances of .NET types. + internal DeliveryItemsFeedResponse(ApiResponse response, IModelProvider modelProvider) : base(response) + { + _items = new Lazy>(() => ((JArray)_response.Content["items"]).Select(source => modelProvider.GetContentItemModel(source, _response.Content["modular_content"])).ToList().AsReadOnly(), LazyThreadSafetyMode.PublicationOnly); + _linkedItems = new Lazy(() => (JObject)_response.Content["modular_content"].DeepClone(), LazyThreadSafetyMode.PublicationOnly); + } + + /// + /// Returns an enumerator that iterates through the collection of content items. + /// + public IEnumerator GetEnumerator() + { + return Items.GetEnumerator(); + } + + /// + /// Returns an enumerator that iterates through the collection of content items. + /// + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index 55287525..7eb0f900 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,59 @@ DeliveryItemListingResponse
listingResponse = await client.GetItemsAsyn See [Working with Strongly Typed Models](../../wiki/Working-with-strongly-typed-models) to learn how to generate models and adjust the logic to your needs. +## Enumerating all items + +There are special cases in which you need to enumerate all items in a project (e.g. cache initialization or project export). This scenario is supported in `IDeliveryClient` by using `DeliveryItemsFeed` that can retrieve items in several batches. + +```csharp +// Get items feed and iteratively fetch all content items in small batches. +DeliveryItemsFeed feed = client.GetItemsFeed(); +List allContentItems = new List(); +while(feed.HasMoreResults) +{ + DeliveryItemsFeedResponse response = await feed.FetchNextBatchAsync(); + allContentItems.AddRange(response); +} +``` + +### Strongly-typed models + +There is also a strongly-typed equivalent of the feed in `IDeliveryClient` to support enumerating into a custom model. + +```csharp +// Get strongly-typed items feed and iteratively fetch all content items in small batches. +DeliveryItemsFeed
feed = client.GetItemsFeed
(); +List
allContentItems = new List
(); +while(feed.HasMoreResults) +{ + DeliveryItemsFeedResponse
response = await feed.FetchNextBatchAsync(); + allContentItems.AddRange(response); +} +``` + +### Filtering + +Filtering is very similar to `GetItems` method, except for `DepthParameter`, `LimitParameter`, and `SkipParameter`. These are not supported in items feed. + +```csharp +// Get a filtered feed of the specified elements of +// the 'brewer' content type, ordered by the 'product_name' element value +DeliveryItemsFeed feed = await client.GetItemsFeed( + new LanguageParameter("es-ES"), + new EqualsFilter("system.type", "brewer"), + new ElementsParameter("image", "price", "product_status", "processing"), + new OrderParameter("elements.product_name") +); +``` + +## Limitations + +Since this method has very specific usage scenarios the response does not contain linked items, although, components are still included in the response. + +Due to not supported skip and limit parameters, the size of a single batch may vary and it is not recommended to dependend on it in any way. The only guaranteed outcome is that once `HasMoreResults` property is false, you will have retrieved all the filtered items. + +**If you need to retrieve all the items and don't care about modular content, feel free to use the items feed. Otherwise, stick with the `GetItems` overloads.** + ## Previewing unpublished content To retrieve unpublished content, you need to create an instance of the `IDeliveryClient` with both Project ID and Preview API key. Each Kentico Kontent project has its own Preview API key. @@ -150,6 +203,10 @@ When retrieving a list of content items, you get an instance of the `DeliveryIte * `NextPageUrl`: the URL of the next page * A list of the requested content items +### Content items feed response + +When retrieving an items feed, you get an instance of the `DeliverItemsFeedResponse`. This class represents the JSON response from the Delivery API endpoint and contains a list of requested content items. + ### ContentItem structure The `ContentItem` class provides the following: @@ -237,6 +294,8 @@ foreach (var option in element.Options) articleItem.GetLinkedItems("related_articles") ``` +If items feed is used to retrieve content items, only components can be retrieved by this method. + ## Using the Image transformations The [ImageUrlBuilder class](https://github.com/Kentico/delivery-sdk-net/blob/master/Kentico.Kontent.Delivery/ImageTransformation/ImageUrlBuilder.cs) exposes methods for applying image transformations on the Asset URL. From 16d47a235e11da3b554b8bc545412f9cced544fa Mon Sep 17 00:00:00 2001 From: Tobias Kamenicky Date: Wed, 18 Sep 2019 15:06:42 +0200 Subject: [PATCH 3/4] KCL-1219 Review changes --- Kentico.Kontent.Delivery/DeliveryClient.cs | 4 +-- .../Responses/DeliveryItemsFeed.cs | 26 +++++++++---------- .../StrongTyping/DeliveryItemsFeed.cs | 26 +++++++++---------- 3 files changed, 26 insertions(+), 30 deletions(-) diff --git a/Kentico.Kontent.Delivery/DeliveryClient.cs b/Kentico.Kontent.Delivery/DeliveryClient.cs index 13ebfe3b..37e8e9bb 100644 --- a/Kentico.Kontent.Delivery/DeliveryClient.cs +++ b/Kentico.Kontent.Delivery/DeliveryClient.cs @@ -233,7 +233,7 @@ public DeliveryItemsFeed GetItemsFeed(IEnumerable parameters) { ValidateItemsFeedParameters(parameters); var endpointUrl = UrlBuilder.GetItemsFeedUrl(parameters); - return new DeliveryItemsFeed(GetItemsBatchAsync, endpointUrl); + return new DeliveryItemsFeed(GetItemsBatchAsync); async Task GetItemsBatchAsync(string continuationToken) { @@ -264,7 +264,7 @@ public DeliveryItemsFeed GetItemsFeed(IEnumerable paramet var enhancedParameters = ExtractParameters(parameters).ToList(); ValidateItemsFeedParameters(enhancedParameters); var endpointUrl = UrlBuilder.GetItemsFeedUrl(enhancedParameters); - return new DeliveryItemsFeed(GetItemsBatchAsync, endpointUrl); + return new DeliveryItemsFeed(GetItemsBatchAsync); async Task> GetItemsBatchAsync(string continuationToken) { diff --git a/Kentico.Kontent.Delivery/Responses/DeliveryItemsFeed.cs b/Kentico.Kontent.Delivery/Responses/DeliveryItemsFeed.cs index 1949fb18..cad90d71 100644 --- a/Kentico.Kontent.Delivery/Responses/DeliveryItemsFeed.cs +++ b/Kentico.Kontent.Delivery/Responses/DeliveryItemsFeed.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; namespace Kentico.Kontent.Delivery { @@ -9,28 +10,21 @@ public class DeliveryItemsFeed { internal delegate Task GetFeedResponse(string continuationToken); - private DeliveryItemsFeedResponse _lastResponse; + private string _continuationToken; private readonly GetFeedResponse _getFeedResponseAsync; /// /// Indicates whether there are more batches to fetch. /// - public bool HasMoreResults => _lastResponse == null || !string.IsNullOrEmpty(_lastResponse.ContinuationToken); - - /// - /// Gets the URL used to retrieve responses in this feed for debugging purposes. - /// - public string ApiUrl { get; } + public bool HasMoreResults { get; private set; } = true; /// /// Initializes a new instance of class. /// /// Function to retrieve next batch of content items. - /// URL used to retrieve responses for this feed. - internal DeliveryItemsFeed(GetFeedResponse getFeedResponseAsync, string requestUrl) + internal DeliveryItemsFeed(GetFeedResponse getFeedResponseAsync) { _getFeedResponseAsync = getFeedResponseAsync; - ApiUrl = requestUrl; } /// @@ -39,12 +33,16 @@ internal DeliveryItemsFeed(GetFeedResponse getFeedResponseAsync, string requestU /// Instance of class that contains a list of content items. public async Task FetchNextBatchAsync() { - if (HasMoreResults) + if (!HasMoreResults) { - _lastResponse = await _getFeedResponseAsync(_lastResponse?.ContinuationToken); + throw new InvalidOperationException("The feed has already been enumerated and there are no more results."); } - return _lastResponse; + var response = await _getFeedResponseAsync(_continuationToken); + _continuationToken = response.ContinuationToken; + HasMoreResults = !string.IsNullOrEmpty(response.ContinuationToken); + + return response; } } } \ No newline at end of file diff --git a/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemsFeed.cs b/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemsFeed.cs index 401fe628..c9dccc3d 100644 --- a/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemsFeed.cs +++ b/Kentico.Kontent.Delivery/StrongTyping/DeliveryItemsFeed.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; namespace Kentico.Kontent.Delivery { @@ -10,28 +11,21 @@ public class DeliveryItemsFeed { internal delegate Task> GetFeedResponse(string continuationToken); - private DeliveryItemsFeedResponse _lastResponse; + private string _continuationToken = null; private readonly GetFeedResponse _getFeedResponseAsync; /// /// Indicates whether there are more batches to fetch. /// - public bool HasMoreResults => _lastResponse == null || !string.IsNullOrEmpty(_lastResponse.ContinuationToken); - - /// - /// Gets the URL used to retrieve responses in this feed for debugging purposes. - /// - public string ApiUrl { get; } + public bool HasMoreResults { get; private set; } = true; /// /// Initializes a new instance of class. /// /// Function to retrieve next batch of content items. - /// URL used to retrieve responses for this feed. - internal DeliveryItemsFeed(GetFeedResponse getFeedResponseAsync, string requestUrl) + internal DeliveryItemsFeed(GetFeedResponse getFeedResponseAsync) { _getFeedResponseAsync = getFeedResponseAsync; - ApiUrl = requestUrl; } /// @@ -40,12 +34,16 @@ internal DeliveryItemsFeed(GetFeedResponse getFeedResponseAsync, string requestU /// Instance of class that contains a list of strongly typed content items. public async Task> FetchNextBatchAsync() { - if (HasMoreResults) + if (!HasMoreResults) { - _lastResponse = await _getFeedResponseAsync(_lastResponse?.ContinuationToken); + throw new InvalidOperationException("The feed has already been enumerated and there are no more results."); } - return _lastResponse; + var response = await _getFeedResponseAsync(_continuationToken); + _continuationToken = response.ContinuationToken; + HasMoreResults = !string.IsNullOrEmpty(response.ContinuationToken); + + return response; } } } \ No newline at end of file From ba7d0140be41b98c69a1e943a4f5750f2cda4fbf Mon Sep 17 00:00:00 2001 From: Tobias Kamenicky Date: Wed, 18 Sep 2019 15:07:01 +0200 Subject: [PATCH 4/4] KCL-1219 Update readme for items feed --- .../Kentico.Kontent.Delivery.Tests.csproj | 9 ------- README.md | 27 ++++++++++--------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/Kentico.Kontent.Delivery.Tests/Kentico.Kontent.Delivery.Tests.csproj b/Kentico.Kontent.Delivery.Tests/Kentico.Kontent.Delivery.Tests.csproj index d6171b33..af8aa15b 100644 --- a/Kentico.Kontent.Delivery.Tests/Kentico.Kontent.Delivery.Tests.csproj +++ b/Kentico.Kontent.Delivery.Tests/Kentico.Kontent.Delivery.Tests.csproj @@ -58,9 +58,6 @@ - - Always - Always @@ -73,12 +70,6 @@ Always - - Always - - - Always - Always diff --git a/README.md b/README.md index 7eb0f900..fd24a54d 100644 --- a/README.md +++ b/README.md @@ -117,16 +117,20 @@ See [Working with Strongly Typed Models](../../wiki/Working-with-strongly-typed- ## Enumerating all items -There are special cases in which you need to enumerate all items in a project (e.g. cache initialization or project export). This scenario is supported in `IDeliveryClient` by using `DeliveryItemsFeed` that can retrieve items in several batches. +There are special use cases in which you need to get and process a larger amount of items in a project (e.g. cache initialization, project export, static website build). This is supported in `IDeliveryClient` by using `DeliveryItemsFeed` that can iterate over items in small batches. This approach has several advantages: +* You are guaranteed to retrieve all items (as opposed to `GetItemsAsync` and paging when the project is being worked on in Kentico Kontent application) +* You can start processing items right away and use less memory, due to a limited size of each batch +* Even larger projects can be retrieved in a timely manner ```csharp -// Get items feed and iteratively fetch all content items in small batches. +// Get items feed and iteratively process all content items in small batches. DeliveryItemsFeed feed = client.GetItemsFeed(); -List allContentItems = new List(); while(feed.HasMoreResults) { DeliveryItemsFeedResponse response = await feed.FetchNextBatchAsync(); - allContentItems.AddRange(response); + foreach(ContentItem item in response) { + ProcessItem(item); + } } ``` @@ -137,17 +141,18 @@ There is also a strongly-typed equivalent of the feed in `IDeliveryClient` to su ```csharp // Get strongly-typed items feed and iteratively fetch all content items in small batches. DeliveryItemsFeed
feed = client.GetItemsFeed
(); -List
allContentItems = new List
(); while(feed.HasMoreResults) { DeliveryItemsFeedResponse
response = await feed.FetchNextBatchAsync(); - allContentItems.AddRange(response); + foreach(Article article in response) { + ProcessArticle(article); + } } ``` -### Filtering +### Filtering and localization -Filtering is very similar to `GetItems` method, except for `DepthParameter`, `LimitParameter`, and `SkipParameter`. These are not supported in items feed. +Both filtering and language selection are very similar to `GetItems` method, except for `DepthParameter`, `LimitParameter`, and `SkipParameter` parameters. These are not supported in items feed. ```csharp // Get a filtered feed of the specified elements of @@ -160,14 +165,12 @@ DeliveryItemsFeed feed = await client.GetItemsFeed( ); ``` -## Limitations +### Limitations -Since this method has very specific usage scenarios the response does not contain linked items, although, components are still included in the response. +Since this method has specific usage scenarios the response does not contain linked items, although, components are still included in the response. Due to not supported skip and limit parameters, the size of a single batch may vary and it is not recommended to dependend on it in any way. The only guaranteed outcome is that once `HasMoreResults` property is false, you will have retrieved all the filtered items. -**If you need to retrieve all the items and don't care about modular content, feel free to use the items feed. Otherwise, stick with the `GetItems` overloads.** - ## Previewing unpublished content To retrieve unpublished content, you need to create an instance of the `IDeliveryClient` with both Project ID and Preview API key. Each Kentico Kontent project has its own Preview API key.