From 4d98937af9f7f4c4bb42aa382bd8da63f166f348 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Tue, 14 Nov 2023 11:16:12 +0100 Subject: [PATCH] Delivery API nested property expansion and output limiting (#15124) * V2 output expansion + field limiting incl. deprecation of V1 APIs * Performance optimizations for Content and Block based property editors * A little formatting * Support API versioning in Delivery API endpoint matcher policy * Add V2 "expand" and "fields" to Swagger docs * Renamed route for "multiple items by ID" * Review changes * Update src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdMediaApiController.cs Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> * Update src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> * Update src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> * Revert "Performance optimizations for Content and Block based property editors" This reverts commit 0d5a57956b36e94ce951f1dad7a7f3f43eb1f60b. * Introduce explicit API cache levels for property expansion * Friendly handling of bad expand/fields parameters --------- Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> --- .../Content/ByIdContentApiController.cs | 24 +- .../Content/ByIdsContentApiController.cs | 23 +- .../Content/ByRouteContentApiController.cs | 18 +- .../Content/QueryContentApiController.cs | 22 +- .../Media/ByIdMediaApiController.cs | 16 +- .../Media/ByIdsMediaApiController.cs | 17 +- .../Media/ByPathMediaApiController.cs | 16 +- .../Media/QueryMediaApiController.cs | 21 +- .../UmbracoBuilderExtensions.cs | 19 +- .../SwaggerContentDocumentationFilter.cs | 4 +- .../Filters/SwaggerDocumentationFilterBase.cs | 105 +++- .../SwaggerMediaDocumentationFilter.cs | 4 +- ...RequestContextOutputExpansionStrategyV2.cs | 185 +++++++ .../DeliveryApiItemsEndpointsMatcherPolicy.cs | 28 +- .../Umbraco.Cms.Api.Delivery.csproj | 3 + .../Extensions/StringExtensions.cs | 7 +- .../IPublishedPropertyType.cs | 6 + .../PublishedContent/PublishedPropertyType.cs | 27 +- .../IDeliveryApiPropertyValueConverter.cs | 9 + .../ContentPickerValueConverter.cs | 2 + .../MediaPickerValueConverter.cs | 2 + .../MultiNodeTreePickerValueConverter.cs | 2 + .../PublishedElementPropertyBase.cs | 13 +- .../BlockGridPropertyValueConverter.cs | 7 + .../BlockListPropertyValueConverter.cs | 3 + .../MediaPickerWithCropsValueConverter.cs | 2 + .../RteMacroRenderingValueConverter.cs | 2 + .../Property.cs | 2 +- .../Umbraco.Core/DeliveryApi/CacheTests.cs | 1 + .../Umbraco.Core/DeliveryApi/CacheTests.cs | 1 + .../DeliveryApi/DeliveryApiTests.cs | 1 + .../OutputExpansionStrategyTestBase.cs | 456 ++++++++++++++++++ .../OutputExpansionStrategyTests.cs | 444 +---------------- .../OutputExpansionStrategyV2Tests.cs | 355 ++++++++++++++ 34 files changed, 1366 insertions(+), 481 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategyV2.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTestBase.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyV2Tests.cs diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdContentApiController.cs index 877e662da7..dab8ccd6d2 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdContentApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdContentApiController.cs @@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Api.Delivery.Controllers.Content; [ApiVersion("1.0")] +[ApiVersion("2.0")] public class ByIdContentApiController : ContentApiItemControllerBase { private readonly IRequestMemberAccessService _requestMemberAccessService; @@ -48,18 +49,31 @@ public class ByIdContentApiController : ContentApiItemControllerBase : base(apiPublishedContentCache, apiContentResponseBuilder) => _requestMemberAccessService = requestMemberAccessService; - /// - /// Gets a content item by id. - /// - /// The unique identifier of the content item. - /// The content item or not found result. [HttpGet("item/{id:guid}")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(IApiContentResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Please use version 2 of this API. Will be removed in V15.")] public async Task ById(Guid id) + => await HandleRequest(id); + + /// + /// Gets a content item by id. + /// + /// The unique identifier of the content item. + /// The content item or not found result. + [HttpGet("item/{id:guid}")] + [MapToApiVersion("2.0")] + [ProducesResponseType(typeof(IApiContentResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ByIdV20(Guid id) + => await HandleRequest(id); + + private async Task HandleRequest(Guid id) { IPublishedContent? contentItem = ApiPublishedContentCache.GetById(id); diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdsContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdsContentApiController.cs index df7e3b26a4..a58b88a532 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdsContentApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdsContentApiController.cs @@ -12,6 +12,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Api.Delivery.Controllers.Content; [ApiVersion("1.0")] +[ApiVersion("2.0")] public class ByIdsContentApiController : ContentApiItemControllerBase { private readonly IRequestMemberAccessService _requestMemberAccessService; @@ -49,17 +50,29 @@ public class ByIdsContentApiController : ContentApiItemControllerBase : base(apiPublishedContentCache, apiContentResponseBuilder) => _requestMemberAccessService = requestMemberAccessService; - /// - /// Gets content items by ids. - /// - /// The unique identifiers of the content items to retrieve. - /// The content items. [HttpGet("item")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] + [Obsolete("Please use version 2 of this API. Will be removed in V15.")] public async Task Item([FromQuery(Name = "id")] HashSet ids) + => await HandleRequest(ids); + + /// + /// Gets content items by ids. + /// + /// The unique identifiers of the content items to retrieve. + /// The content items. + [HttpGet("items")] + [MapToApiVersion("2.0")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ItemsV20([FromQuery(Name = "id")] HashSet ids) + => await HandleRequest(ids); + + private async Task HandleRequest(HashSet ids) { IPublishedContent[] contentItems = ApiPublishedContentCache.GetByIds(ids).ToArray(); diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByRouteContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByRouteContentApiController.cs index 4806db45ff..b88147999a 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByRouteContentApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByRouteContentApiController.cs @@ -13,6 +13,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Api.Delivery.Controllers.Content; [ApiVersion("1.0")] +[ApiVersion("2.0")] public class ByRouteContentApiController : ContentApiItemControllerBase { private readonly IRequestRoutingService _requestRoutingService; @@ -73,6 +74,16 @@ public class ByRouteContentApiController : ContentApiItemControllerBase _requestMemberAccessService = requestMemberAccessService; } + [HttpGet("item/{*path}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IApiContentResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Please use version 2 of this API. Will be removed in V15.")] + public async Task ByRoute(string path = "") + => await HandleRequest(path); + /// /// Gets a content item by route. /// @@ -83,12 +94,15 @@ public class ByRouteContentApiController : ContentApiItemControllerBase /// /// The content item or not found result. [HttpGet("item/{*path}")] - [MapToApiVersion("1.0")] + [MapToApiVersion("2.0")] [ProducesResponseType(typeof(IApiContentResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task ByRoute(string path = "") + public async Task ByRouteV20(string path = "") + => await HandleRequest(path); + + private async Task HandleRequest(string path) { path = DecodePath(path); diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/QueryContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/QueryContentApiController.cs index 2f4e8af9c8..d726027021 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/QueryContentApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/QueryContentApiController.cs @@ -15,6 +15,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Api.Delivery.Controllers.Content; [ApiVersion("1.0")] +[ApiVersion("2.0")] public class QueryContentApiController : ContentApiControllerBase { private readonly IRequestMemberAccessService _requestMemberAccessService; @@ -45,6 +46,20 @@ public class QueryContentApiController : ContentApiControllerBase _requestMemberAccessService = requestMemberAccessService; } + [HttpGet] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Please use version 2 of this API. Will be removed in V15.")] + public async Task Query( + string? fetch, + [FromQuery] string[] filter, + [FromQuery] string[] sort, + int skip = 0, + int take = 10) + => await HandleRequest(fetch, filter, sort, skip, take); + /// /// Gets a paginated list of content item(s) from query. /// @@ -55,16 +70,19 @@ public class QueryContentApiController : ContentApiControllerBase /// The amount of items to take. /// The paged result of the content item(s). [HttpGet] - [MapToApiVersion("1.0")] + [MapToApiVersion("2.0")] [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task Query( + public async Task QueryV20( string? fetch, [FromQuery] string[] filter, [FromQuery] string[] sort, int skip = 0, int take = 10) + => await HandleRequest(fetch, filter, sort, skip, take); + + private async Task HandleRequest(string? fetch, string[] filter, string[] sort, int skip, int take) { ProtectedAccess protectedAccess = await _requestMemberAccessService.MemberAccessAsync(); Attempt, ApiContentQueryOperationStatus> queryAttempt = _apiContentQueryService.ExecuteQuery(fetch, filter, sort, protectedAccess, skip, take); diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdMediaApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdMediaApiController.cs index b0242bea5d..76ce80898f 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdMediaApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdMediaApiController.cs @@ -9,6 +9,7 @@ using Umbraco.Cms.Infrastructure.DeliveryApi; namespace Umbraco.Cms.Api.Delivery.Controllers.Media; [ApiVersion("1.0")] +[ApiVersion("2.0")] public class ByIdMediaApiController : MediaApiControllerBase { public ByIdMediaApiController(IPublishedSnapshotAccessor publishedSnapshotAccessor, IApiMediaWithCropsResponseBuilder apiMediaWithCropsResponseBuilder) @@ -16,16 +17,27 @@ public class ByIdMediaApiController : MediaApiControllerBase { } + [HttpGet("item/{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IApiMediaWithCropsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Please use version 2 of this API. Will be removed in V15.")] + public async Task ById(Guid id) + => await HandleRequest(id); + /// /// Gets a media item by id. /// /// The unique identifier of the media item. /// The media item or not found result. [HttpGet("item/{id:guid}")] - [MapToApiVersion("1.0")] + [MapToApiVersion("2.0")] [ProducesResponseType(typeof(IApiMediaWithCropsResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task ById(Guid id) + public async Task ByIdV20(Guid id) + => await HandleRequest(id); + + private async Task HandleRequest(Guid id) { IPublishedContent? media = PublishedMediaCache.GetById(id); diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdsMediaApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdsMediaApiController.cs index a9421eaa0c..8a3f4c7ceb 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdsMediaApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdsMediaApiController.cs @@ -10,6 +10,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Api.Delivery.Controllers.Media; [ApiVersion("1.0")] +[ApiVersion("2.0")] public class ByIdsMediaApiController : MediaApiControllerBase { public ByIdsMediaApiController(IPublishedSnapshotAccessor publishedSnapshotAccessor, IApiMediaWithCropsResponseBuilder apiMediaWithCropsResponseBuilder) @@ -17,15 +18,25 @@ public class ByIdsMediaApiController : MediaApiControllerBase { } + [HttpGet("item")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [Obsolete("Please use version 2 of this API. Will be removed in V15.")] + public async Task Item([FromQuery(Name = "id")] HashSet ids) + => await HandleRequest(ids); + /// /// Gets media items by ids. /// /// The unique identifiers of the media items to retrieve. /// The media items. - [HttpGet("item")] - [MapToApiVersion("1.0")] + [HttpGet("items")] + [MapToApiVersion("2.0")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public async Task Item([FromQuery(Name = "id")] HashSet ids) + public async Task ItemsV20([FromQuery(Name = "id")] HashSet ids) + => await HandleRequest(ids); + + private async Task HandleRequest(HashSet ids) { IPublishedContent[] mediaItems = ids .Select(PublishedMediaCache.GetById) diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByPathMediaApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByPathMediaApiController.cs index 1d725ac5ab..ed5dc90187 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByPathMediaApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByPathMediaApiController.cs @@ -10,6 +10,7 @@ using Umbraco.Cms.Infrastructure.DeliveryApi; namespace Umbraco.Cms.Api.Delivery.Controllers.Media; [ApiVersion("1.0")] +[ApiVersion("2.0")] public class ByPathMediaApiController : MediaApiControllerBase { private readonly IApiMediaQueryService _apiMediaQueryService; @@ -21,16 +22,27 @@ public class ByPathMediaApiController : MediaApiControllerBase : base(publishedSnapshotAccessor, apiMediaWithCropsResponseBuilder) => _apiMediaQueryService = apiMediaQueryService; + [HttpGet("item/{*path}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IApiMediaWithCropsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Please use version 2 of this API. Will be removed in V15.")] + public async Task ByPath(string path) + => await HandleRequest(path); + /// /// Gets a media item by its path. /// /// The path of the media item. /// The media item or not found result. [HttpGet("item/{*path}")] - [MapToApiVersion("1.0")] + [MapToApiVersion("2.0")] [ProducesResponseType(typeof(IApiMediaWithCropsResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task ByPath(string path) + public async Task ByPathV20(string path) + => await HandleRequest(path); + + private async Task HandleRequest(string path) { path = DecodePath(path); diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Media/QueryMediaApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Media/QueryMediaApiController.cs index 5d962ea4bf..de872e5d86 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Media/QueryMediaApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Media/QueryMediaApiController.cs @@ -15,6 +15,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Api.Delivery.Controllers.Media; [ApiVersion("1.0")] +[ApiVersion("2.0")] public class QueryMediaApiController : MediaApiControllerBase { private readonly IApiMediaQueryService _apiMediaQueryService; @@ -26,6 +27,19 @@ public class QueryMediaApiController : MediaApiControllerBase : base(publishedSnapshotAccessor, apiMediaWithCropsResponseBuilder) => _apiMediaQueryService = apiMediaQueryService; + [HttpGet] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [Obsolete("Please use version 2 of this API. Will be removed in V15.")] + public async Task Query( + string? fetch, + [FromQuery] string[] filter, + [FromQuery] string[] sort, + int skip = 0, + int take = 10) + => await HandleRequest(fetch, filter, sort, skip, take); + /// /// Gets a paginated list of media item(s) from query. /// @@ -36,15 +50,18 @@ public class QueryMediaApiController : MediaApiControllerBase /// The amount of items to take. /// The paged result of the media item(s). [HttpGet] - [MapToApiVersion("1.0")] + [MapToApiVersion("2.0")] [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] - public async Task Query( + public async Task QueryV20( string? fetch, [FromQuery] string[] filter, [FromQuery] string[] sort, int skip = 0, int take = 10) + => await HandleRequest(fetch, filter, sort, skip, take); + + private async Task HandleRequest(string? fetch, string[] filter, string[] sort, int skip, int take) { Attempt, ApiMediaQueryOperationStatus> queryAttempt = _apiMediaQueryService.ExecuteQuery(fetch, filter, sort, skip, take); diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index 86c8583708..f17fc14773 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -1,5 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Asp.Versioning; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Common.DependencyInjection; @@ -24,7 +26,22 @@ public static class UmbracoBuilderExtensions public static IUmbracoBuilder AddDeliveryApi(this IUmbracoBuilder builder) { builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(provider => + { + HttpContext? httpContext = provider.GetRequiredService().HttpContext; + ApiVersion? apiVersion = httpContext?.GetRequestedApiVersion(); + if (apiVersion is null) + { + return provider.GetRequiredService(); + } + + // V1 of the Delivery API uses a different expansion strategy than V2+ + return apiVersion.MajorVersion == 1 + ? provider.GetRequiredService() + : provider.GetRequiredService(); + }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs index 7c42a3d0aa..9d938cef41 100644 --- a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs @@ -15,7 +15,9 @@ internal sealed class SwaggerContentDocumentationFilter : SwaggerDocumentationFi { operation.Parameters ??= new List(); - AddExpand(operation); + AddExpand(operation, context); + + AddFields(operation, context); operation.Parameters.Add(new OpenApiParameter { diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs index 36721cc0f2..52acddaca9 100644 --- a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs @@ -37,8 +37,52 @@ internal abstract class SwaggerDocumentationFilterBase parameter.Examples = examples; } - protected void AddExpand(OpenApiOperation operation) => + protected void AddExpand(OpenApiOperation operation, OperationFilterContext context) + { + if (IsApiV1(context)) + { + AddExpandV1(operation); + } + else + { + AddExpand(operation); + } + } + + protected void AddFields(OpenApiOperation operation, OperationFilterContext context) + { + if (IsApiV1(context)) + { + // "fields" is not a thing in Delivery API V1 + return; + } + + AddFields(operation); + } + + protected void AddApiKey(OpenApiOperation operation) => operation.Parameters.Add(new OpenApiParameter + { + Name = "Api-Key", + In = ParameterLocation.Header, + Required = false, + Description = "API key specified through configuration to authorize access to the API.", + Schema = new OpenApiSchema { Type = "string" } + }); + + protected string PaginationDescription(bool skip, string itemType) + => $"Specifies the number of found {itemType} items to {(skip ? "skip" : "take")}. Use this to control pagination of the response."; + + private string QueryParameterDescription(string description) + => $"{description}. Refer to [the documentation]({DocumentationLink}#query-parameters) for more details on this."; + + // FIXME: remove this when Delivery API V1 has been removed (expectedly in V15) + private static bool IsApiV1(OperationFilterContext context) + => context.ApiDescription.RelativePath?.Contains("api/v1") is true; + + // FIXME: remove this when Delivery API V1 has been removed (expectedly in V15) + private void AddExpandV1(OpenApiOperation operation) + => operation.Parameters.Add(new OpenApiParameter { Name = "expand", In = ParameterLocation.Query, @@ -60,19 +104,56 @@ internal abstract class SwaggerDocumentationFilterBase } }); - protected void AddApiKey(OpenApiOperation operation) => - operation.Parameters.Add(new OpenApiParameter + private void AddExpand(OpenApiOperation operation) + => operation.Parameters.Add(new OpenApiParameter { - Name = "Api-Key", - In = ParameterLocation.Header, + Name = "expand", + In = ParameterLocation.Query, Required = false, - Description = "API key specified through configuration to authorize access to the API.", - Schema = new OpenApiSchema { Type = "string" } + Description = QueryParameterDescription("Defines the properties that should be expanded in the response"), + Schema = new OpenApiSchema { Type = "string" }, + Examples = new Dictionary + { + { "Expand none", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { "Expand all properties", new OpenApiExample { Value = new OpenApiString("properties[$all]") } }, + { + "Expand specific property", + new OpenApiExample { Value = new OpenApiString("properties[alias1]") } + }, + { + "Expand specific properties", + new OpenApiExample { Value = new OpenApiString("properties[alias1,alias2]") } + }, + { + "Expand nested properties", + new OpenApiExample { Value = new OpenApiString("properties[alias1[properties[nestedAlias1,nestedAlias2]]]") } + } + } }); - protected string PaginationDescription(bool skip, string itemType) - => $"Specifies the number of found {itemType} items to {(skip ? "skip" : "take")}. Use this to control pagination of the response."; - - private string QueryParameterDescription(string description) - => $"{description}. Refer to [the documentation]({DocumentationLink}#query-parameters) for more details on this."; + private void AddFields(OpenApiOperation operation) + => operation.Parameters.Add(new OpenApiParameter + { + Name = "fields", + In = ParameterLocation.Query, + Required = false, + Description = QueryParameterDescription("Explicitly defines which properties should be included in the response (by default all properties are included)"), + Schema = new OpenApiSchema { Type = "string" }, + Examples = new Dictionary + { + { "Include all properties", new OpenApiExample { Value = new OpenApiString("properties[$all]") } }, + { + "Include only specific property", + new OpenApiExample { Value = new OpenApiString("properties[alias1]") } + }, + { + "Include only specific properties", + new OpenApiExample { Value = new OpenApiString("properties[alias1,alias2]") } + }, + { + "Include only specific nested properties", + new OpenApiExample { Value = new OpenApiString("properties[alias1[properties[nestedAlias1,nestedAlias2]]]") } + } + } + }); } diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerMediaDocumentationFilter.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerMediaDocumentationFilter.cs index 8529178888..85ba66e648 100644 --- a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerMediaDocumentationFilter.cs +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerMediaDocumentationFilter.cs @@ -15,7 +15,9 @@ internal sealed class SwaggerMediaDocumentationFilter : SwaggerDocumentationFilt { operation.Parameters ??= new List(); - AddExpand(operation); + AddExpand(operation, context); + + AddFields(operation, context); AddApiKey(operation); } diff --git a/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategyV2.cs b/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategyV2.cs new file mode 100644 index 0000000000..6cc2100e93 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategyV2.cs @@ -0,0 +1,185 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Rendering; + +internal sealed class RequestContextOutputExpansionStrategyV2 : IOutputExpansionStrategy +{ + private const string All = "$all"; + private const string None = ""; + private const string ExpandParameterName = "expand"; + private const string FieldsParameterName = "fields"; + + private readonly IApiPropertyRenderer _propertyRenderer; + private readonly ILogger _logger; + + private readonly Stack _expandProperties; + private readonly Stack _includeProperties; + + public RequestContextOutputExpansionStrategyV2( + IHttpContextAccessor httpContextAccessor, + IApiPropertyRenderer propertyRenderer, + ILogger logger) + { + _propertyRenderer = propertyRenderer; + _logger = logger; + _expandProperties = new Stack(); + _includeProperties = new Stack(); + + InitializeExpandAndInclude(httpContextAccessor); + } + + public IDictionary MapContentProperties(IPublishedContent content) + => content.ItemType == PublishedItemType.Content + ? MapProperties(content.Properties) + : throw new ArgumentException($"Invalid item type. This method can only be used with item type {nameof(PublishedItemType.Content)}, got: {content.ItemType}"); + + public IDictionary MapMediaProperties(IPublishedContent media, bool skipUmbracoProperties = true) + { + if (media.ItemType != PublishedItemType.Media) + { + throw new ArgumentException($"Invalid item type. This method can only be used with item type {PublishedItemType.Media}, got: {media.ItemType}"); + } + + IPublishedProperty[] properties = media + .Properties + .Where(p => skipUmbracoProperties is false || p.Alias.StartsWith("umbraco") is false) + .ToArray(); + + return properties.Any() + ? MapProperties(properties) + : new Dictionary(); + } + + public IDictionary MapElementProperties(IPublishedElement element) + => MapProperties(element.Properties, true); + + private void InitializeExpandAndInclude(IHttpContextAccessor httpContextAccessor) + { + string? QueryValue(string key) => httpContextAccessor.HttpContext?.Request.Query[key]; + + var toExpand = QueryValue(ExpandParameterName) ?? None; + var toInclude = QueryValue(FieldsParameterName) ?? All; + + try + { + _expandProperties.Push(Node.Parse(toExpand)); + } + catch (ArgumentException ex) + { + _logger.LogError(ex, $"Could not parse the '{ExpandParameterName}' parameter. See exception for details."); + throw new ArgumentException($"Could not parse the '{ExpandParameterName}' parameter: {ex.Message}"); + } + + try + { + _includeProperties.Push(Node.Parse(toInclude)); + } + catch (ArgumentException ex) + { + _logger.LogError(ex, $"Could not parse the '{FieldsParameterName}' parameter. See exception for details."); + throw new ArgumentException($"Could not parse the '{FieldsParameterName}' parameter: {ex.Message}"); + } + } + + private IDictionary MapProperties(IEnumerable properties, bool forceExpandProperties = false) + { + Node? currentExpandProperties = _expandProperties.Peek(); + if (_expandProperties.Count > 1 && currentExpandProperties is null && forceExpandProperties is false) + { + return new Dictionary(); + } + + Node? currentIncludeProperties = _includeProperties.Peek(); + var result = new Dictionary(); + foreach (IPublishedProperty property in properties) + { + Node? nextIncludeProperties = GetNextProperties(currentIncludeProperties, property.Alias); + if (currentIncludeProperties is not null && currentIncludeProperties.Items.Any() && nextIncludeProperties is null) + { + continue; + } + + Node? nextExpandProperties = GetNextProperties(currentExpandProperties, property.Alias); + + _includeProperties.Push(nextIncludeProperties); + _expandProperties.Push(nextExpandProperties); + + result[property.Alias] = GetPropertyValue(property); + + _expandProperties.Pop(); + _includeProperties.Pop(); + } + + return result; + } + + private Node? GetNextProperties(Node? currentProperties, string propertyAlias) + => currentProperties?.Items.FirstOrDefault(i => i.Key == All) + ?? currentProperties?.Items.FirstOrDefault(i => i.Key == "properties")?.Items.FirstOrDefault(i => i.Key == All || i.Key == propertyAlias); + + private object? GetPropertyValue(IPublishedProperty property) + => _propertyRenderer.GetPropertyValue(property, _expandProperties.Peek() is not null); + + private class Node + { + public string Key { get; private set; } = string.Empty; + + public List Items { get; } = new(); + + public static Node Parse(string value) + { + // verify that there are as many start brackets as there are end brackets + if (value.CountOccurrences("[") != value.CountOccurrences("]")) + { + throw new ArgumentException("Value did not contain an equal number of start and end brackets"); + } + + // verify that the value does not start with a start bracket + if (value.StartsWith("[")) + { + throw new ArgumentException("Value cannot start with a bracket"); + } + + // verify that there are no empty brackets + if (value.Contains("[]")) + { + throw new ArgumentException("Value cannot contain empty brackets"); + } + + var stack = new Stack(); + var root = new Node { Key = "root" }; + stack.Push(root); + + var currentNode = new Node(); + root.Items.Add(currentNode); + + foreach (char c in value) + { + switch (c) + { + case '[': // Start a new node, child of the current node + stack.Push(currentNode); + currentNode = new Node(); + stack.Peek().Items.Add(currentNode); + break; + case ',': // Start a new node, but at the same level of the current node + currentNode = new Node(); + stack.Peek().Items.Add(currentNode); + break; + case ']': // Back to parent of the current node + currentNode = stack.Pop(); + break; + default: // Add char to current node key + currentNode.Key += c; + break; + } + } + + return root; + } + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Routing/DeliveryApiItemsEndpointsMatcherPolicy.cs b/src/Umbraco.Cms.Api.Delivery/Routing/DeliveryApiItemsEndpointsMatcherPolicy.cs index 52b38414f8..186cd4f555 100644 --- a/src/Umbraco.Cms.Api.Delivery/Routing/DeliveryApiItemsEndpointsMatcherPolicy.cs +++ b/src/Umbraco.Cms.Api.Delivery/Routing/DeliveryApiItemsEndpointsMatcherPolicy.cs @@ -1,3 +1,4 @@ +using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Routing; @@ -29,9 +30,25 @@ internal sealed class DeliveryApiItemsEndpointsMatcherPolicy : MatcherPolicy, IE public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates) { var hasIdQueryParameter = httpContext.Request.Query.ContainsKey("id"); + ApiVersion? requestedApiVersion = httpContext.GetRequestedApiVersion(); for (var i = 0; i < candidates.Count; i++) { - ControllerActionDescriptor? controllerActionDescriptor = candidates[i].Endpoint?.Metadata.GetMetadata(); + CandidateState candidate = candidates[i]; + Endpoint? endpoint = candidate.Endpoint; + + // NOTE: nullability for the CandidateState.Endpoint property is not correct - it *can* be null + if (endpoint is null) + { + continue; + } + + if (EndpointSupportsApiVersion(endpoint, requestedApiVersion) is false) + { + candidates.SetValidity(i, false); + continue; + } + + ControllerActionDescriptor? controllerActionDescriptor = endpoint.Metadata.GetMetadata(); if (IsByIdsController(controllerActionDescriptor)) { candidates.SetValidity(i, hasIdQueryParameter); @@ -45,6 +62,15 @@ internal sealed class DeliveryApiItemsEndpointsMatcherPolicy : MatcherPolicy, IE return Task.CompletedTask; } + private static bool EndpointSupportsApiVersion(Endpoint endpoint, ApiVersion? requestedApiVersion) + { + ApiVersion[]? supportedApiVersions = endpoint.Metadata.GetMetadata()?.Versions.ToArray(); + + // if the endpoint is versioned, the requested API version must be among the API versions supported by the endpoint. + // if the endpoint is NOT versioned, it cannot be used with a requested API version + return supportedApiVersions?.Contains(requestedApiVersion) ?? requestedApiVersion is null; + } + private static bool IsByIdsController(ControllerActionDescriptor? controllerActionDescriptor) => IsControllerType(controllerActionDescriptor) || IsControllerType(controllerActionDescriptor); diff --git a/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj b/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj index 0306dab5af..8948a49a1a 100644 --- a/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj +++ b/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj @@ -13,5 +13,8 @@ <_Parameter1>Umbraco.Tests.UnitTests + + <_Parameter1>DynamicProxyGenAssembly2 + diff --git a/src/Umbraco.Core/Extensions/StringExtensions.cs b/src/Umbraco.Core/Extensions/StringExtensions.cs index e7849eef12..732e25ecc2 100644 --- a/src/Umbraco.Core/Extensions/StringExtensions.cs +++ b/src/Umbraco.Core/Extensions/StringExtensions.cs @@ -419,7 +419,7 @@ public static class StringExtensions /// returns . /// public static bool IsNullOrWhiteSpace([NotNullWhen(false)] this string? value) => string.IsNullOrWhiteSpace(value); - + [return: NotNullIfNotNull("defaultValue")] public static string? IfNullOrWhiteSpace(this string? str, string? defaultValue) => str.IsNullOrWhiteSpace() ? defaultValue : str; @@ -1557,4 +1557,9 @@ public static class StringExtensions yield return sb.ToString(); } + + // having benchmarked various solutions (incl. for/foreach, split and LINQ based ones), + // this is by far the fastest way to find string needles in a string haystack + public static int CountOccurrences(this string haystack, string needle) + => haystack.Length - haystack.Replace(needle, string.Empty).Length; } diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs index 45d36abb6a..4cf5bdd6af 100644 --- a/src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs +++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs @@ -56,6 +56,12 @@ public interface IPublishedPropertyType /// PropertyCacheLevel DeliveryApiCacheLevel { get; } + /// + /// Gets the property cache level for Delivery API representation when expanding the property. + /// + /// Defaults to the value of . + PropertyCacheLevel DeliveryApiCacheLevelForExpansion => DeliveryApiCacheLevel; + /// /// Gets the property model CLR type. /// diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs index 848e961d0b..52e3371767 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs @@ -21,6 +21,7 @@ namespace Umbraco.Cms.Core.Models.PublishedContent private IPropertyValueConverter? _converter; private PropertyCacheLevel _cacheLevel; private PropertyCacheLevel _deliveryApiCacheLevel; + private PropertyCacheLevel _deliveryApiCacheLevelForExpansion; private Type? _modelClrType; private Type? _clrType; @@ -192,9 +193,15 @@ namespace Umbraco.Cms.Core.Models.PublishedContent } _cacheLevel = _converter?.GetPropertyCacheLevel(this) ?? PropertyCacheLevel.Snapshot; - _deliveryApiCacheLevel = _converter is IDeliveryApiPropertyValueConverter deliveryApiPropertyValueConverter - ? deliveryApiPropertyValueConverter.GetDeliveryApiPropertyCacheLevel(this) - : _cacheLevel; + if (_converter is IDeliveryApiPropertyValueConverter deliveryApiPropertyValueConverter) + { + _deliveryApiCacheLevel = deliveryApiPropertyValueConverter.GetDeliveryApiPropertyCacheLevel(this); + _deliveryApiCacheLevelForExpansion = deliveryApiPropertyValueConverter.GetDeliveryApiPropertyCacheLevelForExpansion(this); + } + else + { + _deliveryApiCacheLevel = _deliveryApiCacheLevelForExpansion = _cacheLevel; + } _modelClrType = _converter?.GetPropertyValueType(this) ?? typeof(object); } @@ -244,6 +251,20 @@ namespace Umbraco.Cms.Core.Models.PublishedContent } } + /// + public PropertyCacheLevel DeliveryApiCacheLevelForExpansion + { + get + { + if (!_initialized) + { + Initialize(); + } + + return _deliveryApiCacheLevelForExpansion; + } + } + /// public object? ConvertSourceToInter(IPublishedElement owner, object? source, bool preview) { diff --git a/src/Umbraco.Core/PropertyEditors/DeliveryApi/IDeliveryApiPropertyValueConverter.cs b/src/Umbraco.Core/PropertyEditors/DeliveryApi/IDeliveryApiPropertyValueConverter.cs index 51d9f95873..4d539d95ce 100644 --- a/src/Umbraco.Core/PropertyEditors/DeliveryApi/IDeliveryApiPropertyValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/DeliveryApi/IDeliveryApiPropertyValueConverter.cs @@ -11,6 +11,15 @@ public interface IDeliveryApiPropertyValueConverter : IPropertyValueConverter /// The property cache level. PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType); + /// + /// Gets the property cache level for Delivery API representation when expanding the property. + /// + /// The property type. + /// The property cache level. + /// Defaults to the value of . + PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) + => GetDeliveryApiPropertyCacheLevel(propertyType); + /// /// Gets the type of values returned by the converter for Delivery API representation. /// diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs index 94f2533548..e13f4c3e45 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs @@ -99,6 +99,8 @@ public class ContentPickerValueConverter : PropertyValueConverterBase, IDelivery public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType); + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot; + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(IApiContent); public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview, bool expanding) diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/MediaPickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/MediaPickerValueConverter.cs index 468d4cdd0c..9c7c481752 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/MediaPickerValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/MediaPickerValueConverter.cs @@ -124,6 +124,8 @@ public class MediaPickerValueConverter : PropertyValueConverterBase, IDeliveryAp public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Elements; + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot; + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(IEnumerable); public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview, bool expanding) diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/MultiNodeTreePickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/MultiNodeTreePickerValueConverter.cs index c14a56a0bd..7d512fbb2b 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/MultiNodeTreePickerValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/MultiNodeTreePickerValueConverter.cs @@ -183,6 +183,8 @@ public class MultiNodeTreePickerValueConverter : PropertyValueConverterBase, IDe public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Elements; + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot; + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => GetEntityType(propertyType) switch { diff --git a/src/Umbraco.Core/PublishedCache/PublishedElementPropertyBase.cs b/src/Umbraco.Core/PublishedCache/PublishedElementPropertyBase.cs index 05348a138c..09f63a8254 100644 --- a/src/Umbraco.Core/PublishedCache/PublishedElementPropertyBase.cs +++ b/src/Umbraco.Core/PublishedCache/PublishedElementPropertyBase.cs @@ -89,6 +89,9 @@ internal class PublishedElementPropertyBase : PublishedPropertyBase private void GetDeliveryApiCacheLevels(out PropertyCacheLevel cacheLevel, out PropertyCacheLevel referenceCacheLevel) => GetCacheLevels(PropertyType.DeliveryApiCacheLevel, out cacheLevel, out referenceCacheLevel); + private void GetDeliveryApiCacheLevelsForExpansion(out PropertyCacheLevel cacheLevel, out PropertyCacheLevel referenceCacheLevel) + => GetCacheLevels(PropertyType.DeliveryApiCacheLevelForExpansion, out cacheLevel, out referenceCacheLevel); + private void GetCacheLevels(PropertyCacheLevel propertyTypeCacheLevel, out PropertyCacheLevel cacheLevel, out PropertyCacheLevel referenceCacheLevel) { // based upon the current reference cache level (ReferenceCacheLevel) and this property @@ -223,7 +226,15 @@ internal class PublishedElementPropertyBase : PublishedPropertyBase public override object? GetDeliveryApiValue(bool expanding, string? culture = null, string? segment = null) { - GetDeliveryApiCacheLevels(out PropertyCacheLevel cacheLevel, out PropertyCacheLevel referenceCacheLevel); + PropertyCacheLevel cacheLevel, referenceCacheLevel; + if (expanding) + { + GetDeliveryApiCacheLevelsForExpansion(out cacheLevel, out referenceCacheLevel); + } + else + { + GetDeliveryApiCacheLevels(out cacheLevel, out referenceCacheLevel); + } lock (_locko) { diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueConverter.cs index 5b877bd9b9..d0e1e2ba19 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueConverter.cs @@ -52,6 +52,10 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters public override Type GetPropertyValueType(IPublishedPropertyType propertyType) => typeof(BlockGridModel); + /// + public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) + => PropertyCacheLevel.Element; + /// public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) => ConvertIntermediateToBlockGridModel(propertyType, referenceCacheLevel, inter, preview); @@ -59,6 +63,9 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters /// public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType); + /// + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot; + /// public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(ApiBlockGridModel); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs index 4c65963093..03445094c1 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs @@ -111,6 +111,9 @@ public class BlockListPropertyValueConverter : PropertyValueConverterBase, IDeli /// public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType); + /// + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot; + /// public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(ApiBlockListModel); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs index 817d48687a..8a22ca92fe 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs @@ -151,6 +151,8 @@ public class MediaPickerWithCropsValueConverter : PropertyValueConverterBase, ID public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Elements; + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot; + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(IEnumerable); public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview, bool expanding) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs index 649fbf36df..871595b3bc 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs @@ -144,6 +144,8 @@ public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter, IDel public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Elements; + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot; + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => _deliveryApiSettings.RichTextOutputAsJson ? typeof(IRichTextElement) diff --git a/src/Umbraco.PublishedCache.NuCache/Property.cs b/src/Umbraco.PublishedCache.NuCache/Property.cs index 6a9e1a982c..2892a04f90 100644 --- a/src/Umbraco.PublishedCache.NuCache/Property.cs +++ b/src/Umbraco.PublishedCache.NuCache/Property.cs @@ -323,7 +323,7 @@ internal class Property : PublishedPropertyBase object? value; lock (_locko) { - CacheValue cacheValues = GetCacheValues(PropertyType.DeliveryApiCacheLevel).For(culture, segment); + CacheValue cacheValues = GetCacheValues(expanding ? PropertyType.DeliveryApiCacheLevelForExpansion : PropertyType.DeliveryApiCacheLevel).For(culture, segment); // initial reference cache level always is .Content const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/CacheTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/CacheTests.cs index 750be34ea1..7c13e5780d 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/CacheTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/CacheTests.cs @@ -49,6 +49,7 @@ public class CacheTests var invocationCount = 0; propertyType.SetupGet(p => p.CacheLevel).Returns(cacheLevel); propertyType.SetupGet(p => p.DeliveryApiCacheLevel).Returns(cacheLevel); + propertyType.SetupGet(p => p.DeliveryApiCacheLevelForExpansion).Returns(cacheLevel); propertyType .Setup(p => p.ConvertInterToDeliveryApiObject(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(() => $"Delivery API value: {++invocationCount}"); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/CacheTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/CacheTests.cs index 48007a13bb..194abdc158 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/CacheTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/CacheTests.cs @@ -33,6 +33,7 @@ public class CacheTests : DeliveryApiTests propertyValueConverter.Setup(p => p.IsConverter(It.IsAny())).Returns(true); propertyValueConverter.Setup(p => p.GetPropertyCacheLevel(It.IsAny())).Returns(cacheLevel); propertyValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(cacheLevel); + propertyValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevelForExpansion(It.IsAny())).Returns(cacheLevel); var propertyType = SetupPublishedPropertyType(propertyValueConverter.Object, "something", "Some.Thing"); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs index 886948697a..80733e3cdd 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs @@ -43,6 +43,7 @@ public class DeliveryApiTests deliveryApiPropertyValueConverter.Setup(p => p.IsValue(It.IsAny(), It.IsAny())).Returns(true); deliveryApiPropertyValueConverter.Setup(p => p.GetPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); deliveryApiPropertyValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); + deliveryApiPropertyValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevelForExpansion(It.IsAny())).Returns(PropertyCacheLevel.None); DeliveryApiPropertyType = SetupPublishedPropertyType(deliveryApiPropertyValueConverter.Object, "deliveryApi", "Delivery.Api.Editor"); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTestBase.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTestBase.cs new file mode 100644 index 0000000000..f08ea5545e --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTestBase.cs @@ -0,0 +1,456 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Infrastructure.DeliveryApi; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +/// +/// The tests contained within this class all serve to test property expansion V1 and V2 exactly the same. +/// +public abstract class OutputExpansionStrategyTestBase : PropertyValueConverterTests +{ + private IPublishedContentType _contentType; + private IPublishedContentType _elementType; + private IPublishedContentType _mediaType; + + [SetUp] + public void SetUp() + { + var contentType = new Mock(); + contentType.SetupGet(c => c.Alias).Returns("thePageType"); + contentType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Content); + _contentType = contentType.Object; + var elementType = new Mock(); + elementType.SetupGet(c => c.Alias).Returns("theElementType"); + elementType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Element); + _elementType = elementType.Object; + var mediaType = new Mock(); + mediaType.SetupGet(c => c.Alias).Returns("theMediaType"); + mediaType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Media); + _mediaType = mediaType.Object; + } + + [Test] + public void OutputExpansionStrategy_ExpandsNothingByDefault() + { + var accessor = CreateOutputExpansionStrategyAccessor(false); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + var prop1 = new PublishedElementPropertyBase(DeliveryApiPropertyType, content.Object, false, PropertyCacheLevel.None); + var prop2 = new PublishedElementPropertyBase(DefaultPropertyType, content.Object, false, PropertyCacheLevel.None); + + var contentPickerContent = CreateSimplePickedContent(123, 456); + var contentPickerProperty = CreateContentPickerProperty(content.Object, contentPickerContent.Key, "contentPicker", apiContentBuilder); + + SetupContentMock(content, prop1, prop2, contentPickerProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(3, result.Properties.Count); + Assert.AreEqual("Delivery API value", result.Properties[DeliveryApiPropertyType.Alias]); + Assert.AreEqual("Default value", result.Properties[DefaultPropertyType.Alias]); + var contentPickerOutput = result.Properties["contentPicker"] as ApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(contentPickerContent.Key, contentPickerOutput.Id); + Assert.IsEmpty(contentPickerOutput.Properties); + } + + [Test] + public void OutputExpansionStrategy_CanExpandSpecificContent() + { + var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { "contentPickerTwo" }); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var contentPickerOneContent = CreateSimplePickedContent(12, 34); + var contentPickerOneProperty = CreateContentPickerProperty(content.Object, contentPickerOneContent.Key, "contentPickerOne", apiContentBuilder); + var contentPickerTwoContent = CreateSimplePickedContent(56, 78); + var contentPickerTwoProperty = CreateContentPickerProperty(content.Object, contentPickerTwoContent.Key, "contentPickerTwo", apiContentBuilder); + + SetupContentMock(content, contentPickerOneProperty, contentPickerTwoProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(2, result.Properties.Count); + + var contentPickerOneOutput = result.Properties["contentPickerOne"] as ApiContent; + Assert.IsNotNull(contentPickerOneOutput); + Assert.AreEqual(contentPickerOneContent.Key, contentPickerOneOutput.Id); + Assert.IsEmpty(contentPickerOneOutput.Properties); + + var contentPickerTwoOutput = result.Properties["contentPickerTwo"] as ApiContent; + Assert.IsNotNull(contentPickerTwoOutput); + Assert.AreEqual(contentPickerTwoContent.Key, contentPickerTwoOutput.Id); + Assert.AreEqual(2, contentPickerTwoOutput.Properties.Count); + Assert.AreEqual(56, contentPickerTwoOutput.Properties["numberOne"]); + Assert.AreEqual(78, contentPickerTwoOutput.Properties["numberTwo"]); + } + + [TestCase(false)] + [TestCase(true)] + public void OutputExpansionStrategy_CanExpandSpecificMedia(bool mediaPicker3) + { + var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { "mediaPickerTwo" }); + var apiMediaBuilder = new ApiMediaBuilder( + new ApiContentNameProvider(), + new ApiMediaUrlProvider(PublishedUrlProvider), + Mock.Of(), + accessor); + + var media = new Mock(); + + var mediaPickerOneContent = CreateSimplePickedMedia(12, 34); + var mediaPickerOneProperty = mediaPicker3 + ? CreateMediaPicker3Property(media.Object, mediaPickerOneContent.Key, "mediaPickerOne", apiMediaBuilder) + : CreateMediaPickerProperty(media.Object, mediaPickerOneContent.Key, "mediaPickerOne", apiMediaBuilder); + var mediaPickerTwoContent = CreateSimplePickedMedia(56, 78); + var mediaPickerTwoProperty = mediaPicker3 + ? CreateMediaPicker3Property(media.Object, mediaPickerTwoContent.Key, "mediaPickerTwo", apiMediaBuilder) + : CreateMediaPickerProperty(media.Object, mediaPickerTwoContent.Key, "mediaPickerTwo", apiMediaBuilder); + + SetupMediaMock(media, mediaPickerOneProperty, mediaPickerTwoProperty); + + var result = apiMediaBuilder.Build(media.Object); + + Assert.AreEqual(2, result.Properties.Count); + + var mediaPickerOneOutput = (result.Properties["mediaPickerOne"] as IEnumerable)?.FirstOrDefault(); + Assert.IsNotNull(mediaPickerOneOutput); + Assert.AreEqual(mediaPickerOneContent.Key, mediaPickerOneOutput.Id); + Assert.IsEmpty(mediaPickerOneOutput.Properties); + + var mediaPickerTwoOutput = (result.Properties["mediaPickerTwo"] as IEnumerable)?.FirstOrDefault(); + Assert.IsNotNull(mediaPickerTwoOutput); + Assert.AreEqual(mediaPickerTwoContent.Key, mediaPickerTwoOutput.Id); + Assert.AreEqual(2, mediaPickerTwoOutput.Properties.Count); + Assert.AreEqual(56, mediaPickerTwoOutput.Properties["numberOne"]); + Assert.AreEqual(78, mediaPickerTwoOutput.Properties["numberTwo"]); + } + + [Test] + public void OutputExpansionStrategy_CanExpandAllContent() + { + var accessor = CreateOutputExpansionStrategyAccessor(true); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var contentPickerOneContent = CreateSimplePickedContent(12, 34); + var contentPickerOneProperty = CreateContentPickerProperty(content.Object, contentPickerOneContent.Key, "contentPickerOne", apiContentBuilder); + var contentPickerTwoContent = CreateSimplePickedContent(56, 78); + var contentPickerTwoProperty = CreateContentPickerProperty(content.Object, contentPickerTwoContent.Key, "contentPickerTwo", apiContentBuilder); + + SetupContentMock(content, contentPickerOneProperty, contentPickerTwoProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(2, result.Properties.Count); + + var contentPickerOneOutput = result.Properties["contentPickerOne"] as ApiContent; + Assert.IsNotNull(contentPickerOneOutput); + Assert.AreEqual(contentPickerOneContent.Key, contentPickerOneOutput.Id); + Assert.AreEqual(2, contentPickerOneOutput.Properties.Count); + Assert.AreEqual(12, contentPickerOneOutput.Properties["numberOne"]); + Assert.AreEqual(34, contentPickerOneOutput.Properties["numberTwo"]); + + var contentPickerTwoOutput = result.Properties["contentPickerTwo"] as ApiContent; + Assert.IsNotNull(contentPickerTwoOutput); + Assert.AreEqual(contentPickerTwoContent.Key, contentPickerTwoOutput.Id); + Assert.AreEqual(2, contentPickerTwoOutput.Properties.Count); + Assert.AreEqual(56, contentPickerTwoOutput.Properties["numberOne"]); + Assert.AreEqual(78, contentPickerTwoOutput.Properties["numberTwo"]); + } + + [TestCase("contentPicker", "contentPicker")] + [TestCase("rootPicker", "nestedPicker")] + public void OutputExpansionStrategy_DoesNotExpandNestedContentPicker(string rootPropertyTypeAlias, string nestedPropertyTypeAlias) + { + var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { rootPropertyTypeAlias, nestedPropertyTypeAlias }); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var nestedContentPickerContent = CreateSimplePickedContent(987, 654); + var contentPickerContent = CreateMultiLevelPickedContent(123, nestedContentPickerContent, nestedPropertyTypeAlias, apiContentBuilder); + var contentPickerContentProperty = CreateContentPickerProperty(content.Object, contentPickerContent.Key, rootPropertyTypeAlias, apiContentBuilder); + + SetupContentMock(content, contentPickerContentProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(1, result.Properties.Count); + + var contentPickerOneOutput = result.Properties[rootPropertyTypeAlias] as ApiContent; + Assert.IsNotNull(contentPickerOneOutput); + Assert.AreEqual(contentPickerContent.Key, contentPickerOneOutput.Id); + Assert.AreEqual(2, contentPickerOneOutput.Properties.Count); + Assert.AreEqual(123, contentPickerOneOutput.Properties["number"]); + + var nestedContentPickerOutput = contentPickerOneOutput.Properties[nestedPropertyTypeAlias] as ApiContent; + Assert.IsNotNull(nestedContentPickerOutput); + Assert.AreEqual(nestedContentPickerContent.Key, nestedContentPickerOutput.Id); + Assert.IsEmpty(nestedContentPickerOutput.Properties); + } + + [Test] + public void OutputExpansionStrategy_DoesNotExpandElementsByDefault() + { + var accessor = CreateOutputExpansionStrategyAccessor(false); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + var apiElementBuilder = new ApiElementBuilder(accessor); + + var contentPickerValue = CreateSimplePickedContent(111, 222); + var contentPicker2Value = CreateSimplePickedContent(666, 777); + + var content = new Mock(); + SetupContentMock( + content, + CreateNumberProperty(content.Object, 444, "number"), + CreateElementProperty(content.Object, "element", 333, contentPickerValue.Key, "contentPicker", apiContentBuilder, apiElementBuilder), + CreateElementProperty(content.Object, "element2", 555, contentPicker2Value.Key, "contentPicker", apiContentBuilder, apiElementBuilder)); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(3, result.Properties.Count); + Assert.AreEqual(444, result.Properties["number"]); + + var expectedElementOutputs = new[] + { + new + { + PropertyAlias = "element", + ElementNumber = 333, + ElementContentPicker = contentPickerValue.Key + }, + new + { + PropertyAlias = "element2", + ElementNumber = 555, + ElementContentPicker = contentPicker2Value.Key + } + }; + + foreach (var expectedElementOutput in expectedElementOutputs) + { + var elementOutput = result.Properties[expectedElementOutput.PropertyAlias] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(expectedElementOutput.ElementNumber, elementOutput.Properties["number"]); + var contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(expectedElementOutput.ElementContentPicker, contentPickerOutput.Id); + Assert.AreEqual(0, contentPickerOutput.Properties.Count); + } + } + + [Test] + public void OutputExpansionStrategy_MappingContent_ThrowsOnInvalidItemType() + { + var accessor = CreateOutputExpansionStrategyAccessor(false); + if (accessor.TryGetValue(out IOutputExpansionStrategy outputExpansionStrategy) is false) + { + Assert.Fail("Could not obtain the output expansion strategy"); + } + + Assert.Throws(() => outputExpansionStrategy.MapContentProperties(PublishedMedia)); + } + + [Test] + public void OutputExpansionStrategy_MappingMedia_ThrowsOnInvalidItemType() + { + var accessor = CreateOutputExpansionStrategyAccessor(false); + if (accessor.TryGetValue(out IOutputExpansionStrategy outputExpansionStrategy) is false) + { + Assert.Fail("Could not obtain the output expansion strategy"); + } + + Assert.Throws(() => outputExpansionStrategy.MapMediaProperties(PublishedContent)); + } + + [TestCase(true)] + [TestCase(false)] + public void OutputExpansionStrategy_ForwardsExpansionStateToPropertyValueConverter(bool expanding) + { + var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { expanding ? "theAlias" : "noSuchAlias" }); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var valueConverterMock = new Mock(); + valueConverterMock.Setup(v => v.IsConverter(It.IsAny())).Returns(true); + valueConverterMock.Setup(p => p.IsValue(It.IsAny(), It.IsAny())).Returns(true); + valueConverterMock.Setup(v => v.GetPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); + valueConverterMock.Setup(v => v.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); + valueConverterMock.Setup(v => v.GetDeliveryApiPropertyCacheLevelForExpansion(It.IsAny())).Returns(PropertyCacheLevel.None); + valueConverterMock.Setup(v => v.ConvertIntermediateToDeliveryApiObject( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(expanding ? "Expanding" : "Not expanding"); + + var propertyType = SetupPublishedPropertyType(valueConverterMock.Object, "theAlias", Constants.PropertyEditors.Aliases.Label); + var property = new PublishedElementPropertyBase(propertyType, content.Object, false, PropertyCacheLevel.None, "The Value"); + + SetupContentMock(content, property); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(1, result.Properties.Count); + Assert.AreEqual(expanding ? "Expanding" : "Not expanding", result.Properties["theAlias"] as string); + } + + protected abstract IOutputExpansionStrategyAccessor CreateOutputExpansionStrategyAccessor(string? expand = null, string? fields = null); + + protected IOutputExpansionStrategyAccessor CreateOutputExpansionStrategyAccessor(bool expandAll = false, string[]? expandPropertyAliases = null) + => CreateOutputExpansionStrategyAccessor(FormatExpandSyntax(expandAll, expandPropertyAliases)); + + protected abstract string? FormatExpandSyntax(bool expandAll = false, string[]? expandPropertyAliases = null); + + protected void SetupContentMock(Mock content, params IPublishedProperty[] properties) + { + var key = Guid.NewGuid(); + var name = "The page"; + var urlSegment = "url-segment"; + ConfigurePublishedContentMock(content, key, name, urlSegment, _contentType, properties); + + RegisterContentWithProviders(content.Object); + } + + protected void SetupMediaMock(Mock media, params IPublishedProperty[] properties) + { + var key = Guid.NewGuid(); + var name = "The media"; + var urlSegment = "media-url-segment"; + ConfigurePublishedContentMock(media, key, name, urlSegment, _mediaType, properties); + + RegisterMediaWithProviders(media.Object); + } + + protected IPublishedContent CreateSimplePickedContent(int numberOneValue, int numberTwoValue) + { + var content = new Mock(); + SetupContentMock( + content, + CreateNumberProperty(content.Object, numberOneValue, "numberOne"), + CreateNumberProperty(content.Object, numberTwoValue, "numberTwo")); + + return content.Object; + } + + protected IPublishedContent CreateSimplePickedMedia(int numberOneValue, int numberTwoValue) + { + var media = new Mock(); + SetupMediaMock( + media, + CreateNumberProperty(media.Object, numberOneValue, "numberOne"), + CreateNumberProperty(media.Object, numberTwoValue, "numberTwo")); + + return media.Object; + } + + protected IPublishedContent CreateMultiLevelPickedContent(int numberValue, IPublishedContent nestedContentPickerValue, string nestedContentPickerPropertyTypeAlias, ApiContentBuilder apiContentBuilder) + { + var content = new Mock(); + SetupContentMock( + content, + CreateNumberProperty(content.Object, numberValue, "number"), + CreateContentPickerProperty(content.Object, nestedContentPickerValue.Key, nestedContentPickerPropertyTypeAlias, apiContentBuilder)); + + return content.Object; + } + + internal PublishedElementPropertyBase CreateContentPickerProperty(IPublishedElement parent, Guid pickedContentKey, string propertyTypeAlias, IApiContentBuilder contentBuilder) + { + ContentPickerValueConverter contentPickerValueConverter = new ContentPickerValueConverter(PublishedSnapshotAccessor, contentBuilder); + var contentPickerPropertyType = SetupPublishedPropertyType(contentPickerValueConverter, propertyTypeAlias, Constants.PropertyEditors.Aliases.ContentPicker); + + return new PublishedElementPropertyBase(contentPickerPropertyType, parent, false, PropertyCacheLevel.None, new GuidUdi(Constants.UdiEntityType.Document, pickedContentKey).ToString()); + } + + internal PublishedElementPropertyBase CreateMediaPickerProperty(IPublishedElement parent, Guid pickedMediaKey, string propertyTypeAlias, IApiMediaBuilder mediaBuilder) + { + MediaPickerValueConverter mediaPickerValueConverter = new MediaPickerValueConverter(PublishedSnapshotAccessor, Mock.Of(), mediaBuilder); + var mediaPickerPropertyType = SetupPublishedPropertyType(mediaPickerValueConverter, propertyTypeAlias, Constants.PropertyEditors.Aliases.MediaPicker, new MediaPickerConfiguration()); + + return new PublishedElementPropertyBase(mediaPickerPropertyType, parent, false, PropertyCacheLevel.None, new GuidUdi(Constants.UdiEntityType.Media, pickedMediaKey).ToString()); + } + + internal PublishedElementPropertyBase CreateMediaPicker3Property(IPublishedElement parent, Guid pickedMediaKey, string propertyTypeAlias, IApiMediaBuilder mediaBuilder) + { + var serializer = new JsonNetSerializer(); + var value = serializer.Serialize(new[] + { + new MediaPicker3PropertyEditor.MediaPicker3PropertyValueEditor.MediaWithCropsDto + { + MediaKey = pickedMediaKey + } + }); + + var publishedValueFallback = Mock.Of(); + var apiMediaWithCropsBuilder = new ApiMediaWithCropsBuilder(mediaBuilder, publishedValueFallback); + + MediaPickerWithCropsValueConverter mediaPickerValueConverter = new MediaPickerWithCropsValueConverter(PublishedSnapshotAccessor, PublishedUrlProvider, publishedValueFallback, new JsonNetSerializer(), apiMediaWithCropsBuilder); + var mediaPickerPropertyType = SetupPublishedPropertyType(mediaPickerValueConverter, propertyTypeAlias, Constants.PropertyEditors.Aliases.MediaPicker3, new MediaPicker3Configuration()); + + return new PublishedElementPropertyBase(mediaPickerPropertyType, parent, false, PropertyCacheLevel.None, value); + } + + internal PublishedElementPropertyBase CreateNumberProperty(IPublishedElement parent, int propertyValue, string propertyTypeAlias) + { + var numberPropertyType = SetupPublishedPropertyType(new IntegerValueConverter(), propertyTypeAlias, Constants.PropertyEditors.Aliases.Label); + return new PublishedElementPropertyBase(numberPropertyType, parent, false, PropertyCacheLevel.None, propertyValue); + } + + internal PublishedElementPropertyBase CreateElementProperty( + IPublishedElement parent, + string elementPropertyAlias, + int numberPropertyValue, + Guid contentPickerPropertyValue, + string contentPickerPropertyTypeAlias, + IApiContentBuilder apiContentBuilder, + IApiElementBuilder apiElementBuilder) + { + var element = new Mock(); + element.SetupGet(c => c.ContentType).Returns(_elementType); + element.SetupGet(c => c.Properties).Returns(new[] + { + CreateNumberProperty(element.Object, numberPropertyValue, "number"), + CreateContentPickerProperty(element.Object, contentPickerPropertyValue, contentPickerPropertyTypeAlias, apiContentBuilder) + }); + + var elementValueConverter = new Mock(); + elementValueConverter + .Setup(p => p.ConvertIntermediateToDeliveryApiObject( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(() => apiElementBuilder.Build(element.Object)); + elementValueConverter.Setup(p => p.IsConverter(It.IsAny())).Returns(true); + elementValueConverter.Setup(p => p.IsValue(It.IsAny(), It.IsAny())).Returns(true); + elementValueConverter.Setup(p => p.GetPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); + elementValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); + elementValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevelForExpansion(It.IsAny())).Returns(PropertyCacheLevel.None); + + var elementPropertyType = SetupPublishedPropertyType(elementValueConverter.Object, elementPropertyAlias, "My.Element.Property"); + return new PublishedElementPropertyBase(elementPropertyType, parent, false, PropertyCacheLevel.None); + } + + protected IApiContentRouteBuilder ApiContentRouteBuilder() => CreateContentRouteBuilder(PublishedUrlProvider, CreateGlobalSettings()); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs index 75612932c8..34258a362f 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs @@ -3,258 +3,19 @@ using Microsoft.Extensions.Primitives; using Moq; using NUnit.Framework; using Umbraco.Cms.Api.Delivery.Rendering; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; -using Umbraco.Cms.Core.PropertyEditors; -using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; -using Umbraco.Cms.Core.PropertyEditors.ValueConverters; -using Umbraco.Cms.Core.PublishedCache; -using Umbraco.Cms.Infrastructure.DeliveryApi; -using Umbraco.Cms.Infrastructure.Serialization; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; +/// +/// Any tests contained within this class specifically test property expansion V1 and not V2. If the aim is to test both +/// versions, please put the tests in the base class. +/// [TestFixture] -public class OutputExpansionStrategyTests : PropertyValueConverterTests +public class OutputExpansionStrategyTests : OutputExpansionStrategyTestBase { - private IPublishedContentType _contentType; - private IPublishedContentType _elementType; - private IPublishedContentType _mediaType; - - [SetUp] - public void SetUp() - { - var contentType = new Mock(); - contentType.SetupGet(c => c.Alias).Returns("thePageType"); - contentType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Content); - _contentType = contentType.Object; - var elementType = new Mock(); - elementType.SetupGet(c => c.Alias).Returns("theElementType"); - elementType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Element); - _elementType = elementType.Object; - var mediaType = new Mock(); - mediaType.SetupGet(c => c.Alias).Returns("theMediaType"); - mediaType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Media); - _mediaType = mediaType.Object; - } - - [Test] - public void OutputExpansionStrategy_ExpandsNothingByDefault() - { - var accessor = CreateOutputExpansionStrategyAccessor(); - var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); - - var content = new Mock(); - var prop1 = new PublishedElementPropertyBase(DeliveryApiPropertyType, content.Object, false, PropertyCacheLevel.None); - var prop2 = new PublishedElementPropertyBase(DefaultPropertyType, content.Object, false, PropertyCacheLevel.None); - - var contentPickerContent = CreateSimplePickedContent(123, 456); - var contentPickerProperty = CreateContentPickerProperty(content.Object, contentPickerContent.Key, "contentPicker", apiContentBuilder); - - SetupContentMock(content, prop1, prop2, contentPickerProperty); - - var result = apiContentBuilder.Build(content.Object); - - Assert.AreEqual(3, result.Properties.Count); - Assert.AreEqual("Delivery API value", result.Properties[DeliveryApiPropertyType.Alias]); - Assert.AreEqual("Default value", result.Properties[DefaultPropertyType.Alias]); - var contentPickerOutput = result.Properties["contentPicker"] as ApiContent; - Assert.IsNotNull(contentPickerOutput); - Assert.AreEqual(contentPickerContent.Key, contentPickerOutput.Id); - Assert.IsEmpty(contentPickerOutput.Properties); - } - - [Test] - public void OutputExpansionStrategy_CanExpandSpecificContent() - { - var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { "contentPickerTwo" }); - var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); - - var content = new Mock(); - - var contentPickerOneContent = CreateSimplePickedContent(12, 34); - var contentPickerOneProperty = CreateContentPickerProperty(content.Object, contentPickerOneContent.Key, "contentPickerOne", apiContentBuilder); - var contentPickerTwoContent = CreateSimplePickedContent(56, 78); - var contentPickerTwoProperty = CreateContentPickerProperty(content.Object, contentPickerTwoContent.Key, "contentPickerTwo", apiContentBuilder); - - SetupContentMock(content, contentPickerOneProperty, contentPickerTwoProperty); - - var result = apiContentBuilder.Build(content.Object); - - Assert.AreEqual(2, result.Properties.Count); - - var contentPickerOneOutput = result.Properties["contentPickerOne"] as ApiContent; - Assert.IsNotNull(contentPickerOneOutput); - Assert.AreEqual(contentPickerOneContent.Key, contentPickerOneOutput.Id); - Assert.IsEmpty(contentPickerOneOutput.Properties); - - var contentPickerTwoOutput = result.Properties["contentPickerTwo"] as ApiContent; - Assert.IsNotNull(contentPickerTwoOutput); - Assert.AreEqual(contentPickerTwoContent.Key, contentPickerTwoOutput.Id); - Assert.AreEqual(2, contentPickerTwoOutput.Properties.Count); - Assert.AreEqual(56, contentPickerTwoOutput.Properties["numberOne"]); - Assert.AreEqual(78, contentPickerTwoOutput.Properties["numberTwo"]); - } - - [TestCase(false)] - [TestCase(true)] - public void OutputExpansionStrategy_CanExpandSpecificMedia(bool mediaPicker3) - { - var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { "mediaPickerTwo" }); - var apiMediaBuilder = new ApiMediaBuilder( - new ApiContentNameProvider(), - new ApiMediaUrlProvider(PublishedUrlProvider), - Mock.Of(), - accessor); - - var media = new Mock(); - - var mediaPickerOneContent = CreateSimplePickedMedia(12, 34); - var mediaPickerOneProperty = mediaPicker3 - ? CreateMediaPicker3Property(media.Object, mediaPickerOneContent.Key, "mediaPickerOne", apiMediaBuilder) - : CreateMediaPickerProperty(media.Object, mediaPickerOneContent.Key, "mediaPickerOne", apiMediaBuilder); - var mediaPickerTwoContent = CreateSimplePickedMedia(56, 78); - var mediaPickerTwoProperty = mediaPicker3 - ? CreateMediaPicker3Property(media.Object, mediaPickerTwoContent.Key, "mediaPickerTwo", apiMediaBuilder) - : CreateMediaPickerProperty(media.Object, mediaPickerTwoContent.Key, "mediaPickerTwo", apiMediaBuilder); - - SetupMediaMock(media, mediaPickerOneProperty, mediaPickerTwoProperty); - - var result = apiMediaBuilder.Build(media.Object); - - Assert.AreEqual(2, result.Properties.Count); - - var mediaPickerOneOutput = (result.Properties["mediaPickerOne"] as IEnumerable)?.FirstOrDefault(); - Assert.IsNotNull(mediaPickerOneOutput); - Assert.AreEqual(mediaPickerOneContent.Key, mediaPickerOneOutput.Id); - Assert.IsEmpty(mediaPickerOneOutput.Properties); - - var mediaPickerTwoOutput = (result.Properties["mediaPickerTwo"] as IEnumerable)?.FirstOrDefault(); - Assert.IsNotNull(mediaPickerTwoOutput); - Assert.AreEqual(mediaPickerTwoContent.Key, mediaPickerTwoOutput.Id); - Assert.AreEqual(2, mediaPickerTwoOutput.Properties.Count); - Assert.AreEqual(56, mediaPickerTwoOutput.Properties["numberOne"]); - Assert.AreEqual(78, mediaPickerTwoOutput.Properties["numberTwo"]); - } - - [Test] - public void OutputExpansionStrategy_CanExpandAllContent() - { - var accessor = CreateOutputExpansionStrategyAccessor(true); - var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); - - var content = new Mock(); - - var contentPickerOneContent = CreateSimplePickedContent(12, 34); - var contentPickerOneProperty = CreateContentPickerProperty(content.Object, contentPickerOneContent.Key, "contentPickerOne", apiContentBuilder); - var contentPickerTwoContent = CreateSimplePickedContent(56, 78); - var contentPickerTwoProperty = CreateContentPickerProperty(content.Object, contentPickerTwoContent.Key, "contentPickerTwo", apiContentBuilder); - - SetupContentMock(content, contentPickerOneProperty, contentPickerTwoProperty); - - var result = apiContentBuilder.Build(content.Object); - - Assert.AreEqual(2, result.Properties.Count); - - var contentPickerOneOutput = result.Properties["contentPickerOne"] as ApiContent; - Assert.IsNotNull(contentPickerOneOutput); - Assert.AreEqual(contentPickerOneContent.Key, contentPickerOneOutput.Id); - Assert.AreEqual(2, contentPickerOneOutput.Properties.Count); - Assert.AreEqual(12, contentPickerOneOutput.Properties["numberOne"]); - Assert.AreEqual(34, contentPickerOneOutput.Properties["numberTwo"]); - - var contentPickerTwoOutput = result.Properties["contentPickerTwo"] as ApiContent; - Assert.IsNotNull(contentPickerTwoOutput); - Assert.AreEqual(contentPickerTwoContent.Key, contentPickerTwoOutput.Id); - Assert.AreEqual(2, contentPickerTwoOutput.Properties.Count); - Assert.AreEqual(56, contentPickerTwoOutput.Properties["numberOne"]); - Assert.AreEqual(78, contentPickerTwoOutput.Properties["numberTwo"]); - } - - [TestCase("contentPicker", "contentPicker")] - [TestCase("rootPicker", "nestedPicker")] - public void OutputExpansionStrategy_DoesNotExpandNestedContentPicker(string rootPropertyTypeAlias, string nestedPropertyTypeAlias) - { - var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { rootPropertyTypeAlias, nestedPropertyTypeAlias }); - var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); - - var content = new Mock(); - - var nestedContentPickerContent = CreateSimplePickedContent(987, 654); - var contentPickerContent = CreateMultiLevelPickedContent(123, nestedContentPickerContent, nestedPropertyTypeAlias, apiContentBuilder); - var contentPickerContentProperty = CreateContentPickerProperty(content.Object, contentPickerContent.Key, rootPropertyTypeAlias, apiContentBuilder); - - SetupContentMock(content, contentPickerContentProperty); - - var result = apiContentBuilder.Build(content.Object); - - Assert.AreEqual(1, result.Properties.Count); - - var contentPickerOneOutput = result.Properties[rootPropertyTypeAlias] as ApiContent; - Assert.IsNotNull(contentPickerOneOutput); - Assert.AreEqual(contentPickerContent.Key, contentPickerOneOutput.Id); - Assert.AreEqual(2, contentPickerOneOutput.Properties.Count); - Assert.AreEqual(123, contentPickerOneOutput.Properties["number"]); - - var nestedContentPickerOutput = contentPickerOneOutput.Properties[nestedPropertyTypeAlias] as ApiContent; - Assert.IsNotNull(nestedContentPickerOutput); - Assert.AreEqual(nestedContentPickerContent.Key, nestedContentPickerOutput.Id); - Assert.IsEmpty(nestedContentPickerOutput.Properties); - } - - [Test] - public void OutputExpansionStrategy_DoesNotExpandElementsByDefault() - { - var accessor = CreateOutputExpansionStrategyAccessor(); - var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); - var apiElementBuilder = new ApiElementBuilder(accessor); - - var contentPickerValue = CreateSimplePickedContent(111, 222); - var contentPicker2Value = CreateSimplePickedContent(666, 777); - - var content = new Mock(); - SetupContentMock( - content, - CreateNumberProperty(content.Object, 444, "number"), - CreateElementProperty(content.Object, "element", 333, contentPickerValue.Key, "contentPicker", apiContentBuilder, apiElementBuilder), - CreateElementProperty(content.Object, "element2", 555, contentPicker2Value.Key, "contentPicker", apiContentBuilder, apiElementBuilder)); - - var result = apiContentBuilder.Build(content.Object); - - Assert.AreEqual(3, result.Properties.Count); - Assert.AreEqual(444, result.Properties["number"]); - - var expectedElementOutputs = new[] - { - new - { - PropertyAlias = "element", - ElementNumber = 333, - ElementContentPicker = contentPickerValue.Key - }, - new - { - PropertyAlias = "element2", - ElementNumber = 555, - ElementContentPicker = contentPicker2Value.Key - } - }; - - foreach (var expectedElementOutput in expectedElementOutputs) - { - var elementOutput = result.Properties[expectedElementOutput.PropertyAlias] as IApiElement; - Assert.IsNotNull(elementOutput); - Assert.AreEqual(2, elementOutput.Properties.Count); - Assert.AreEqual(expectedElementOutput.ElementNumber, elementOutput.Properties["number"]); - var contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; - Assert.IsNotNull(contentPickerOutput); - Assert.AreEqual(expectedElementOutput.ElementContentPicker, contentPickerOutput.Id); - Assert.AreEqual(0, contentPickerOutput.Properties.Count); - } - } - [Test] public void OutputExpansionStrategy_CanExpandSpecifiedElement() { @@ -387,71 +148,12 @@ public class OutputExpansionStrategyTests : PropertyValueConverterTests Assert.AreEqual(0, nestedContentPickerOutput.Properties.Count); } - [Test] - public void OutputExpansionStrategy_MappingContent_ThrowsOnInvalidItemType() - { - var accessor = CreateOutputExpansionStrategyAccessor(); - if (accessor.TryGetValue(out IOutputExpansionStrategy outputExpansionStrategy) is false) - { - Assert.Fail("Could not obtain the output expansion strategy"); - } - - Assert.Throws(() => outputExpansionStrategy.MapContentProperties(PublishedMedia)); - } - - [Test] - public void OutputExpansionStrategy_MappingMedia_ThrowsOnInvalidItemType() - { - var accessor = CreateOutputExpansionStrategyAccessor(); - if (accessor.TryGetValue(out IOutputExpansionStrategy outputExpansionStrategy) is false) - { - Assert.Fail("Could not obtain the output expansion strategy"); - } - - Assert.Throws(() => outputExpansionStrategy.MapMediaProperties(PublishedContent)); - } - - [TestCase(true)] - [TestCase(false)] - public void OutputExpansionStrategy_ForwardsExpansionStateToPropertyValueConverter(bool expanding) - { - var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { expanding ? "theAlias" : "noSuchAlias" }); - var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); - - var content = new Mock(); - - var valueConverterMock = new Mock(); - valueConverterMock.Setup(v => v.IsConverter(It.IsAny())).Returns(true); - valueConverterMock.Setup(p => p.IsValue(It.IsAny(), It.IsAny())).Returns(true); - valueConverterMock.Setup(v => v.GetPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); - valueConverterMock.Setup(v => v.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); - valueConverterMock.Setup(v => v.ConvertIntermediateToDeliveryApiObject( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(expanding ? "Expanding" : "Not expanding"); - - var propertyType = SetupPublishedPropertyType(valueConverterMock.Object, "theAlias", Constants.PropertyEditors.Aliases.Label); - var property = new PublishedElementPropertyBase(propertyType, content.Object, false, PropertyCacheLevel.None, "The Value"); - - SetupContentMock(content, property); - - var result = apiContentBuilder.Build(content.Object); - - Assert.AreEqual(1, result.Properties.Count); - Assert.AreEqual(expanding ? "Expanding" : "Not expanding", result.Properties["theAlias"] as string); - } - - private IOutputExpansionStrategyAccessor CreateOutputExpansionStrategyAccessor(bool expandAll = false, string[]? expandPropertyAliases = null) + protected override IOutputExpansionStrategyAccessor CreateOutputExpansionStrategyAccessor(string? expand = null, string? fields = null) { var httpContextMock = new Mock(); var httpRequestMock = new Mock(); var httpContextAccessorMock = new Mock(); - var expand = expandAll ? "all" : expandPropertyAliases != null ? $"property:{string.Join(",", expandPropertyAliases)}" : null; httpRequestMock .SetupGet(r => r.Query) .Returns(new QueryCollection(new Dictionary { { "expand", expand } })); @@ -466,136 +168,6 @@ public class OutputExpansionStrategyTests : PropertyValueConverterTests return outputExpansionStrategyAccessorMock.Object; } - private void SetupContentMock(Mock content, params IPublishedProperty[] properties) - { - var key = Guid.NewGuid(); - var name = "The page"; - var urlSegment = "url-segment"; - ConfigurePublishedContentMock(content, key, name, urlSegment, _contentType, properties); - - RegisterContentWithProviders(content.Object); - } - - private void SetupMediaMock(Mock media, params IPublishedProperty[] properties) - { - var key = Guid.NewGuid(); - var name = "The media"; - var urlSegment = "media-url-segment"; - ConfigurePublishedContentMock(media, key, name, urlSegment, _mediaType, properties); - - RegisterMediaWithProviders(media.Object); - } - - private IPublishedContent CreateSimplePickedContent(int numberOneValue, int numberTwoValue) - { - var content = new Mock(); - SetupContentMock( - content, - CreateNumberProperty(content.Object, numberOneValue, "numberOne"), - CreateNumberProperty(content.Object, numberTwoValue, "numberTwo")); - - return content.Object; - } - - private IPublishedContent CreateSimplePickedMedia(int numberOneValue, int numberTwoValue) - { - var media = new Mock(); - SetupMediaMock( - media, - CreateNumberProperty(media.Object, numberOneValue, "numberOne"), - CreateNumberProperty(media.Object, numberTwoValue, "numberTwo")); - - return media.Object; - } - - private IPublishedContent CreateMultiLevelPickedContent(int numberValue, IPublishedContent nestedContentPickerValue, string nestedContentPickerPropertyTypeAlias, ApiContentBuilder apiContentBuilder) - { - var content = new Mock(); - SetupContentMock( - content, - CreateNumberProperty(content.Object, numberValue, "number"), - CreateContentPickerProperty(content.Object, nestedContentPickerValue.Key, nestedContentPickerPropertyTypeAlias, apiContentBuilder)); - - return content.Object; - } - - private PublishedElementPropertyBase CreateContentPickerProperty(IPublishedElement parent, Guid pickedContentKey, string propertyTypeAlias, IApiContentBuilder contentBuilder) - { - ContentPickerValueConverter contentPickerValueConverter = new ContentPickerValueConverter(PublishedSnapshotAccessor, contentBuilder); - var contentPickerPropertyType = SetupPublishedPropertyType(contentPickerValueConverter, propertyTypeAlias, Constants.PropertyEditors.Aliases.ContentPicker); - - return new PublishedElementPropertyBase(contentPickerPropertyType, parent, false, PropertyCacheLevel.None, new GuidUdi(Constants.UdiEntityType.Document, pickedContentKey).ToString()); - } - - private PublishedElementPropertyBase CreateMediaPickerProperty(IPublishedElement parent, Guid pickedMediaKey, string propertyTypeAlias, IApiMediaBuilder mediaBuilder) - { - MediaPickerValueConverter mediaPickerValueConverter = new MediaPickerValueConverter(PublishedSnapshotAccessor, Mock.Of(), mediaBuilder); - var mediaPickerPropertyType = SetupPublishedPropertyType(mediaPickerValueConverter, propertyTypeAlias, Constants.PropertyEditors.Aliases.MediaPicker, new MediaPickerConfiguration()); - - return new PublishedElementPropertyBase(mediaPickerPropertyType, parent, false, PropertyCacheLevel.None, new GuidUdi(Constants.UdiEntityType.Media, pickedMediaKey).ToString()); - } - - private PublishedElementPropertyBase CreateMediaPicker3Property(IPublishedElement parent, Guid pickedMediaKey, string propertyTypeAlias, IApiMediaBuilder mediaBuilder) - { - var serializer = new JsonNetSerializer(); - var value = serializer.Serialize(new[] - { - new MediaPicker3PropertyEditor.MediaPicker3PropertyValueEditor.MediaWithCropsDto - { - MediaKey = pickedMediaKey - } - }); - - var publishedValueFallback = Mock.Of(); - var apiMediaWithCropsBuilder = new ApiMediaWithCropsBuilder(mediaBuilder, publishedValueFallback); - - MediaPickerWithCropsValueConverter mediaPickerValueConverter = new MediaPickerWithCropsValueConverter(PublishedSnapshotAccessor, PublishedUrlProvider, publishedValueFallback, new JsonNetSerializer(), apiMediaWithCropsBuilder); - var mediaPickerPropertyType = SetupPublishedPropertyType(mediaPickerValueConverter, propertyTypeAlias, Constants.PropertyEditors.Aliases.MediaPicker3, new MediaPicker3Configuration()); - - return new PublishedElementPropertyBase(mediaPickerPropertyType, parent, false, PropertyCacheLevel.None, value); - } - - private PublishedElementPropertyBase CreateNumberProperty(IPublishedElement parent, int propertyValue, string propertyTypeAlias) - { - var numberPropertyType = SetupPublishedPropertyType(new IntegerValueConverter(), propertyTypeAlias, Constants.PropertyEditors.Aliases.Label); - return new PublishedElementPropertyBase(numberPropertyType, parent, false, PropertyCacheLevel.None, propertyValue); - } - - private PublishedElementPropertyBase CreateElementProperty( - IPublishedElement parent, - string elementPropertyAlias, - int numberPropertyValue, - Guid contentPickerPropertyValue, - string contentPickerPropertyTypeAlias, - IApiContentBuilder apiContentBuilder, - IApiElementBuilder apiElementBuilder) - { - var element = new Mock(); - element.SetupGet(c => c.ContentType).Returns(_elementType); - element.SetupGet(c => c.Properties).Returns(new[] - { - CreateNumberProperty(element.Object, numberPropertyValue, "number"), - CreateContentPickerProperty(element.Object, contentPickerPropertyValue, contentPickerPropertyTypeAlias, apiContentBuilder) - }); - - var elementValueConverter = new Mock(); - elementValueConverter - .Setup(p => p.ConvertIntermediateToDeliveryApiObject( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(() => apiElementBuilder.Build(element.Object)); - elementValueConverter.Setup(p => p.IsConverter(It.IsAny())).Returns(true); - elementValueConverter.Setup(p => p.IsValue(It.IsAny(), It.IsAny())).Returns(true); - elementValueConverter.Setup(p => p.GetPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); - elementValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); - - var elementPropertyType = SetupPublishedPropertyType(elementValueConverter.Object, elementPropertyAlias, "My.Element.Property"); - return new PublishedElementPropertyBase(elementPropertyType, parent, false, PropertyCacheLevel.None); - } - - private IApiContentRouteBuilder ApiContentRouteBuilder() => CreateContentRouteBuilder(PublishedUrlProvider, CreateGlobalSettings()); + protected override string? FormatExpandSyntax(bool expandAll = false, string[]? expandPropertyAliases = null) + => expandAll ? "all" : expandPropertyAliases?.Any() is true ? $"property:{string.Join(",", expandPropertyAliases)}" : null; } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyV2Tests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyV2Tests.cs new file mode 100644 index 0000000000..7619d1055a --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyV2Tests.cs @@ -0,0 +1,355 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Api.Delivery.Rendering; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +/// +/// Any tests contained within this class specifically test property expansion V2 (and field limiting) - not V1. If the +/// aim is to test expansion for both versions, please put the tests in the base class. +/// +[TestFixture] +public class OutputExpansionStrategyV2Tests : OutputExpansionStrategyTestBase +{ + [TestCase("contentPicker", "contentPicker")] + [TestCase("rootPicker", "nestedPicker")] + public void OutputExpansionStrategy_CanExpandNestedContentPicker(string rootPropertyTypeAlias, string nestedPropertyTypeAlias) + { + var accessor = CreateOutputExpansionStrategyAccessor($"properties[{rootPropertyTypeAlias}[properties[{nestedPropertyTypeAlias}]]]"); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var nestedContentPickerContent = CreateSimplePickedContent(987, 654); + var contentPickerContent = CreateMultiLevelPickedContent(123, nestedContentPickerContent, nestedPropertyTypeAlias, apiContentBuilder); + var contentPickerContentProperty = CreateContentPickerProperty(content.Object, contentPickerContent.Key, rootPropertyTypeAlias, apiContentBuilder); + + SetupContentMock(content, contentPickerContentProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(1, result.Properties.Count); + + var contentPickerOneOutput = result.Properties[rootPropertyTypeAlias] as ApiContent; + Assert.IsNotNull(contentPickerOneOutput); + Assert.AreEqual(contentPickerContent.Key, contentPickerOneOutput.Id); + Assert.AreEqual(2, contentPickerOneOutput.Properties.Count); + Assert.AreEqual(123, contentPickerOneOutput.Properties["number"]); + + var nestedContentPickerOutput = contentPickerOneOutput.Properties[nestedPropertyTypeAlias] as ApiContent; + Assert.IsNotNull(nestedContentPickerOutput); + Assert.AreEqual(nestedContentPickerContent.Key, nestedContentPickerOutput.Id); + Assert.IsNotEmpty(nestedContentPickerOutput.Properties); + Assert.AreEqual(987, nestedContentPickerOutput.Properties["numberOne"]); + Assert.AreEqual(654, nestedContentPickerOutput.Properties["numberTwo"]); + } + + [Test] + public void OutputExpansionStrategy_CanExpandSpecifiedElement() + { + // var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { "element" }); + var accessor = CreateOutputExpansionStrategyAccessor("properties[element[properties[$all]]]"); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + var apiElementBuilder = new ApiElementBuilder(accessor); + + var contentPickerValue = CreateSimplePickedContent(111, 222); + var contentPicker2Value = CreateSimplePickedContent(666, 777); + + var content = new Mock(); + SetupContentMock( + content, + CreateNumberProperty(content.Object, 444, "number"), + CreateElementProperty(content.Object, "element", 333, contentPickerValue.Key, "contentPicker", apiContentBuilder, apiElementBuilder), + CreateElementProperty(content.Object, "element2", 555, contentPicker2Value.Key, "contentPicker", apiContentBuilder, apiElementBuilder)); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(3, result.Properties.Count); + Assert.AreEqual(444, result.Properties["number"]); + + var elementOutput = result.Properties["element"] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(333, elementOutput.Properties["number"]); + var contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(contentPickerValue.Key, contentPickerOutput.Id); + Assert.AreEqual(2, contentPickerOutput.Properties.Count); + Assert.AreEqual(111, contentPickerOutput.Properties["numberOne"]); + Assert.AreEqual(222, contentPickerOutput.Properties["numberTwo"]); + + elementOutput = result.Properties["element2"] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(555, elementOutput.Properties["number"]); + contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(contentPicker2Value.Key, contentPickerOutput.Id); + Assert.AreEqual(0, contentPickerOutput.Properties.Count); + } + + [Test] + public void OutputExpansionStrategy_CanExpandAllElements() + { + var accessor = CreateOutputExpansionStrategyAccessor("properties[element[properties[$all]],element2[properties[$all]]]" ); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + var apiElementBuilder = new ApiElementBuilder(accessor); + + var contentPickerValue = CreateSimplePickedContent(111, 222); + var contentPicker2Value = CreateSimplePickedContent(666, 777); + + var content = new Mock(); + SetupContentMock( + content, + CreateNumberProperty(content.Object, 444, "number"), + CreateElementProperty(content.Object, "element", 333, contentPickerValue.Key, "contentPicker", apiContentBuilder, apiElementBuilder), + CreateElementProperty(content.Object, "element2", 555, contentPicker2Value.Key, "contentPicker", apiContentBuilder, apiElementBuilder)); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(3, result.Properties.Count); + Assert.AreEqual(444, result.Properties["number"]); + + var expectedElementOutputs = new[] + { + new + { + PropertyAlias = "element", + ElementNumber = 333, + ElementContentPicker = contentPickerValue.Key, + ContentNumberOne = 111, + ContentNumberTwo = 222 + }, + new + { + PropertyAlias = "element2", + ElementNumber = 555, + ElementContentPicker = contentPicker2Value.Key, + ContentNumberOne = 666, + ContentNumberTwo = 777 + } + }; + + foreach (var expectedElementOutput in expectedElementOutputs) + { + var elementOutput = result.Properties[expectedElementOutput.PropertyAlias] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(expectedElementOutput.ElementNumber, elementOutput.Properties["number"]); + var contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(expectedElementOutput.ElementContentPicker, contentPickerOutput.Id); + Assert.AreEqual(2, contentPickerOutput.Properties.Count); + Assert.AreEqual(expectedElementOutput.ContentNumberOne, contentPickerOutput.Properties["numberOne"]); + Assert.AreEqual(expectedElementOutput.ContentNumberTwo, contentPickerOutput.Properties["numberTwo"]); + } + } + + [Test] + public void OutputExpansionStrategy_DoesNotExpandElementNestedContentPicker() + { + var accessor = CreateOutputExpansionStrategyAccessor("properties[element[properties[contentPicker]]]" ); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + var apiElementBuilder = new ApiElementBuilder(accessor); + + var nestedContentPickerValue = CreateSimplePickedContent(111, 222); + var contentPickerValue = CreateMultiLevelPickedContent(987, nestedContentPickerValue, "contentPicker", apiContentBuilder); + + var content = new Mock(); + SetupContentMock(content, CreateElementProperty(content.Object, "element", 333, contentPickerValue.Key, "contentPicker", apiContentBuilder, apiElementBuilder)); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(1, result.Properties.Count); + + var elementOutput = result.Properties["element"] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(333, elementOutput.Properties["number"]); + var contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(contentPickerValue.Key, contentPickerOutput.Id); + Assert.AreEqual(2, contentPickerOutput.Properties.Count); + Assert.AreEqual(987, contentPickerOutput.Properties["number"]); + var nestedContentPickerOutput = contentPickerOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(nestedContentPickerOutput); + Assert.AreEqual(nestedContentPickerValue.Key, nestedContentPickerOutput.Id); + Assert.AreEqual(0, nestedContentPickerOutput.Properties.Count); + } + + [Test] + public void OutputExpansionStrategy_CanExpandElementNestedContentPicker() + { + var accessor = CreateOutputExpansionStrategyAccessor("properties[element[properties[contentPicker[properties[nestedContentPicker]]]]]"); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + var apiElementBuilder = new ApiElementBuilder(accessor); + + var nestedContentPickerValue = CreateSimplePickedContent(111, 222); + var contentPickerValue = CreateMultiLevelPickedContent(987, nestedContentPickerValue, "nestedContentPicker", apiContentBuilder); + + var content = new Mock(); + SetupContentMock(content, CreateElementProperty(content.Object, "element", 333, contentPickerValue.Key, "contentPicker", apiContentBuilder, apiElementBuilder)); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(1, result.Properties.Count); + + var elementOutput = result.Properties["element"] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(333, elementOutput.Properties["number"]); + var contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(contentPickerValue.Key, contentPickerOutput.Id); + Assert.AreEqual(2, contentPickerOutput.Properties.Count); + Assert.AreEqual(987, contentPickerOutput.Properties["number"]); + var nestedContentPickerOutput = contentPickerOutput.Properties["nestedContentPicker"] as IApiContent; + Assert.IsNotNull(nestedContentPickerOutput); + Assert.AreEqual(nestedContentPickerValue.Key, nestedContentPickerOutput.Id); + Assert.AreEqual(2, nestedContentPickerOutput.Properties.Count); + Assert.AreEqual(111, nestedContentPickerOutput.Properties["numberOne"]); + Assert.AreEqual(222, nestedContentPickerOutput.Properties["numberTwo"]); + } + + [Test] + public void OutputExpansionStrategy_CanExpandContentPickerBeyondTwoLevels() + { + var accessor = CreateOutputExpansionStrategyAccessor($"properties[level1Picker[properties[level2Picker[properties[level3Picker[properties[level4Picker]]]]]]]"); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var level5PickedContent = CreateSimplePickedContent(1234, 5678); + var level4PickedContent = CreateMultiLevelPickedContent(444, level5PickedContent, "level4Picker", apiContentBuilder); + var level3PickedContent = CreateMultiLevelPickedContent(333, level4PickedContent, "level3Picker", apiContentBuilder); + var level2PickedContent = CreateMultiLevelPickedContent(222, level3PickedContent, "level2Picker", apiContentBuilder); + var contentPickerContentProperty = CreateContentPickerProperty(content.Object, level2PickedContent.Key, "level1Picker", apiContentBuilder); + + SetupContentMock(content, contentPickerContentProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(1, result.Properties.Count); + + var level1PickerOutput = result.Properties["level1Picker"] as ApiContent; + Assert.IsNotNull(level1PickerOutput); + Assert.AreEqual(level2PickedContent.Key, level1PickerOutput.Id); + Assert.AreEqual(2, level1PickerOutput.Properties.Count); + Assert.AreEqual(222, level1PickerOutput.Properties["number"]); + + var level2PickerOutput = level1PickerOutput.Properties["level2Picker"] as ApiContent; + Assert.IsNotNull(level2PickerOutput); + Assert.AreEqual(level3PickedContent.Key, level2PickerOutput.Id); + Assert.AreEqual(2, level2PickerOutput.Properties.Count); + Assert.AreEqual(333, level2PickerOutput.Properties["number"]); + + var level3PickerOutput = level2PickerOutput.Properties["level3Picker"] as ApiContent; + Assert.IsNotNull(level3PickerOutput); + Assert.AreEqual(level4PickedContent.Key, level3PickerOutput.Id); + Assert.AreEqual(2, level3PickerOutput.Properties.Count); + Assert.AreEqual(444, level3PickerOutput.Properties["number"]); + + var level4PickerOutput = level3PickerOutput.Properties["level4Picker"] as ApiContent; + Assert.IsNotNull(level4PickerOutput); + Assert.AreEqual(level5PickedContent.Key, level4PickerOutput.Id); + Assert.AreEqual(2, level4PickerOutput.Properties.Count); + Assert.AreEqual(1234, level4PickerOutput.Properties["numberOne"]); + Assert.AreEqual(5678, level4PickerOutput.Properties["numberTwo"]); + } + + [TestCase("numberOne")] + [TestCase("numberTwo")] + public void OutputExpansionStrategy_CanLimitDirectFields(string includedField) + { + var accessor = CreateOutputExpansionStrategyAccessor(fields: $"properties[{includedField}]"); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = CreateSimplePickedContent(123, 456); + + var result = apiContentBuilder.Build(content); + + Assert.AreEqual(1, result.Properties.Count); + Assert.IsTrue(result.Properties.ContainsKey(includedField)); + Assert.AreEqual(includedField is "numberOne" ? 123 : 456, result.Properties[includedField]); + } + + [TestCase(false)] + [TestCase(true)] + public void OutputExpansionStrategy_CanLimitFieldsOfExpandedContent(bool expand) + { + var accessor = CreateOutputExpansionStrategyAccessor(expand ? "properties[$all]" : null, "properties[contentPickerOne[properties[numberOne]],contentPickerTwo[properties[numberTwo]]]"); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var contentPickerOneContent = CreateSimplePickedContent(12, 34); + var contentPickerOneProperty = CreateContentPickerProperty(content.Object, contentPickerOneContent.Key, "contentPickerOne", apiContentBuilder); + var contentPickerTwoContent = CreateSimplePickedContent(56, 78); + var contentPickerTwoProperty = CreateContentPickerProperty(content.Object, contentPickerTwoContent.Key, "contentPickerTwo", apiContentBuilder); + + SetupContentMock(content, contentPickerOneProperty, contentPickerTwoProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(2, result.Properties.Count); + + var contentPickerOneOutput = result.Properties["contentPickerOne"] as ApiContent; + Assert.IsNotNull(contentPickerOneOutput); + Assert.AreEqual(contentPickerOneContent.Key, contentPickerOneOutput.Id); + // yeah we shouldn't test two things in one unit test, but given the risk of false positives when testing + // conditional field limiting, this is preferable. + if (expand) + { + Assert.AreEqual(1, contentPickerOneOutput.Properties.Count); + Assert.AreEqual(12, contentPickerOneOutput.Properties["numberOne"]); + } + else + { + Assert.IsEmpty(contentPickerOneOutput.Properties); + } + + var contentPickerTwoOutput = result.Properties["contentPickerTwo"] as ApiContent; + Assert.IsNotNull(contentPickerTwoOutput); + Assert.AreEqual(contentPickerTwoContent.Key, contentPickerTwoOutput.Id); + if (expand) + { + Assert.AreEqual(1, contentPickerTwoOutput.Properties.Count); + Assert.AreEqual(78, contentPickerTwoOutput.Properties["numberTwo"]); + } + else + { + Assert.IsEmpty(contentPickerTwoOutput.Properties); + } + } + + protected override IOutputExpansionStrategyAccessor CreateOutputExpansionStrategyAccessor(string? expand = null, string? fields = null) + { + var httpContextMock = new Mock(); + var httpRequestMock = new Mock(); + var httpContextAccessorMock = new Mock(); + + httpRequestMock + .SetupGet(r => r.Query) + .Returns(new QueryCollection(new Dictionary { { "expand", expand }, { "fields", fields } })); + + httpContextMock.SetupGet(c => c.Request).Returns(httpRequestMock.Object); + httpContextAccessorMock.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); + IOutputExpansionStrategy outputExpansionStrategy = new RequestContextOutputExpansionStrategyV2( + httpContextAccessorMock.Object, + new ApiPropertyRenderer(new NoopPublishedValueFallback()), + Mock.Of>()); + var outputExpansionStrategyAccessorMock = new Mock(); + outputExpansionStrategyAccessorMock.Setup(s => s.TryGetValue(out outputExpansionStrategy)).Returns(true); + + return outputExpansionStrategyAccessorMock.Object; + } + + protected override string? FormatExpandSyntax(bool expandAll = false, string[]? expandPropertyAliases = null) + => expandAll ? "$all" : expandPropertyAliases?.Any() is true ? $"properties[{string.Join(",", expandPropertyAliases)}]" : null; +}