Merge remote-tracking branch 'origin/release/13.0' into v13/dev

This commit is contained in:
Bjarke Berg
2023-11-14 12:43:11 +01:00
34 changed files with 1366 additions and 481 deletions

View File

@@ -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;
/// <summary>
/// Gets a content item by id.
/// </summary>
/// <param name="id">The unique identifier of the content item.</param>
/// <returns>The content item or not found result.</returns>
[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<IActionResult> ById(Guid id)
=> await HandleRequest(id);
/// <summary>
/// Gets a content item by id.
/// </summary>
/// <param name="id">The unique identifier of the content item.</param>
/// <returns>The content item or not found result.</returns>
[HttpGet("item/{id:guid}")]
[MapToApiVersion("2.0")]
[ProducesResponseType(typeof(IApiContentResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ByIdV20(Guid id)
=> await HandleRequest(id);
private async Task<IActionResult> HandleRequest(Guid id)
{
IPublishedContent? contentItem = ApiPublishedContentCache.GetById(id);

View File

@@ -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;
/// <summary>
/// Gets content items by ids.
/// </summary>
/// <param name="ids">The unique identifiers of the content items to retrieve.</param>
/// <returns>The content items.</returns>
[HttpGet("item")]
[MapToApiVersion("1.0")]
[ProducesResponseType(typeof(IEnumerable<IApiContentResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[Obsolete("Please use version 2 of this API. Will be removed in V15.")]
public async Task<IActionResult> Item([FromQuery(Name = "id")] HashSet<Guid> ids)
=> await HandleRequest(ids);
/// <summary>
/// Gets content items by ids.
/// </summary>
/// <param name="ids">The unique identifiers of the content items to retrieve.</param>
/// <returns>The content items.</returns>
[HttpGet("items")]
[MapToApiVersion("2.0")]
[ProducesResponseType(typeof(IEnumerable<IApiContentResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ItemsV20([FromQuery(Name = "id")] HashSet<Guid> ids)
=> await HandleRequest(ids);
private async Task<IActionResult> HandleRequest(HashSet<Guid> ids)
{
IPublishedContent[] contentItems = ApiPublishedContentCache.GetByIds(ids).ToArray();

View File

@@ -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<IActionResult> ByRoute(string path = "")
=> await HandleRequest(path);
/// <summary>
/// Gets a content item by route.
/// </summary>
@@ -83,12 +94,15 @@ public class ByRouteContentApiController : ContentApiItemControllerBase
/// </remarks>
/// <returns>The content item or not found result.</returns>
[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<IActionResult> ByRoute(string path = "")
public async Task<IActionResult> ByRouteV20(string path = "")
=> await HandleRequest(path);
private async Task<IActionResult> HandleRequest(string path)
{
path = DecodePath(path);

View File

@@ -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<IApiContentResponse>), 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<IActionResult> Query(
string? fetch,
[FromQuery] string[] filter,
[FromQuery] string[] sort,
int skip = 0,
int take = 10)
=> await HandleRequest(fetch, filter, sort, skip, take);
/// <summary>
/// Gets a paginated list of content item(s) from query.
/// </summary>
@@ -55,16 +70,19 @@ public class QueryContentApiController : ContentApiControllerBase
/// <param name="take">The amount of items to take.</param>
/// <returns>The paged result of the content item(s).</returns>
[HttpGet]
[MapToApiVersion("1.0")]
[MapToApiVersion("2.0")]
[ProducesResponseType(typeof(PagedViewModel<IApiContentResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Query(
public async Task<IActionResult> 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<IActionResult> HandleRequest(string? fetch, string[] filter, string[] sort, int skip, int take)
{
ProtectedAccess protectedAccess = await _requestMemberAccessService.MemberAccessAsync();
Attempt<PagedModel<Guid>, ApiContentQueryOperationStatus> queryAttempt = _apiContentQueryService.ExecuteQuery(fetch, filter, sort, protectedAccess, skip, take);

View File

@@ -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<IActionResult> ById(Guid id)
=> await HandleRequest(id);
/// <summary>
/// Gets a media item by id.
/// </summary>
/// <param name="id">The unique identifier of the media item.</param>
/// <returns>The media item or not found result.</returns>
[HttpGet("item/{id:guid}")]
[MapToApiVersion("1.0")]
[MapToApiVersion("2.0")]
[ProducesResponseType(typeof(IApiMediaWithCropsResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ById(Guid id)
public async Task<IActionResult> ByIdV20(Guid id)
=> await HandleRequest(id);
private async Task<IActionResult> HandleRequest(Guid id)
{
IPublishedContent? media = PublishedMediaCache.GetById(id);

View File

@@ -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<IApiMediaWithCropsResponse>), StatusCodes.Status200OK)]
[Obsolete("Please use version 2 of this API. Will be removed in V15.")]
public async Task<IActionResult> Item([FromQuery(Name = "id")] HashSet<Guid> ids)
=> await HandleRequest(ids);
/// <summary>
/// Gets media items by ids.
/// </summary>
/// <param name="ids">The unique identifiers of the media items to retrieve.</param>
/// <returns>The media items.</returns>
[HttpGet("item")]
[MapToApiVersion("1.0")]
[HttpGet("items")]
[MapToApiVersion("2.0")]
[ProducesResponseType(typeof(IEnumerable<IApiMediaWithCropsResponse>), StatusCodes.Status200OK)]
public async Task<ActionResult> Item([FromQuery(Name = "id")] HashSet<Guid> ids)
public async Task<IActionResult> ItemsV20([FromQuery(Name = "id")] HashSet<Guid> ids)
=> await HandleRequest(ids);
private async Task<IActionResult> HandleRequest(HashSet<Guid> ids)
{
IPublishedContent[] mediaItems = ids
.Select(PublishedMediaCache.GetById)

View File

@@ -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<IActionResult> ByPath(string path)
=> await HandleRequest(path);
/// <summary>
/// Gets a media item by its path.
/// </summary>
/// <param name="path">The path of the media item.</param>
/// <returns>The media item or not found result.</returns>
[HttpGet("item/{*path}")]
[MapToApiVersion("1.0")]
[MapToApiVersion("2.0")]
[ProducesResponseType(typeof(IApiMediaWithCropsResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ByPath(string path)
public async Task<IActionResult> ByPathV20(string path)
=> await HandleRequest(path);
private async Task<IActionResult> HandleRequest(string path)
{
path = DecodePath(path);

View File

@@ -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<IApiMediaWithCropsResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
[Obsolete("Please use version 2 of this API. Will be removed in V15.")]
public async Task<IActionResult> Query(
string? fetch,
[FromQuery] string[] filter,
[FromQuery] string[] sort,
int skip = 0,
int take = 10)
=> await HandleRequest(fetch, filter, sort, skip, take);
/// <summary>
/// Gets a paginated list of media item(s) from query.
/// </summary>
@@ -36,15 +50,18 @@ public class QueryMediaApiController : MediaApiControllerBase
/// <param name="take">The amount of items to take.</param>
/// <returns>The paged result of the media item(s).</returns>
[HttpGet]
[MapToApiVersion("1.0")]
[MapToApiVersion("2.0")]
[ProducesResponseType(typeof(PagedViewModel<IApiMediaWithCropsResponse>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Query(
public async Task<IActionResult> 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<IActionResult> HandleRequest(string? fetch, string[] filter, string[] sort, int skip, int take)
{
Attempt<PagedModel<Guid>, ApiMediaQueryOperationStatus> queryAttempt = _apiMediaQueryService.ExecuteQuery(fetch, filter, sort, skip, take);

View File

@@ -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<IRequestStartItemProvider, RequestStartItemProvider>();
builder.Services.AddScoped<IOutputExpansionStrategy, RequestContextOutputExpansionStrategy>();
builder.Services.AddScoped<RequestContextOutputExpansionStrategy>();
builder.Services.AddScoped<RequestContextOutputExpansionStrategyV2>();
builder.Services.AddScoped<IOutputExpansionStrategy>(provider =>
{
HttpContext? httpContext = provider.GetRequiredService<IHttpContextAccessor>().HttpContext;
ApiVersion? apiVersion = httpContext?.GetRequestedApiVersion();
if (apiVersion is null)
{
return provider.GetRequiredService<RequestContextOutputExpansionStrategyV2>();
}
// V1 of the Delivery API uses a different expansion strategy than V2+
return apiVersion.MajorVersion == 1
? provider.GetRequiredService<RequestContextOutputExpansionStrategy>()
: provider.GetRequiredService<RequestContextOutputExpansionStrategyV2>();
});
builder.Services.AddSingleton<IRequestCultureService, RequestCultureService>();
builder.Services.AddSingleton<IRequestRoutingService, RequestRoutingService>();
builder.Services.AddSingleton<IRequestRedirectService, RequestRedirectService>();

View File

@@ -15,7 +15,9 @@ internal sealed class SwaggerContentDocumentationFilter : SwaggerDocumentationFi
{
operation.Parameters ??= new List<OpenApiParameter>();
AddExpand(operation);
AddExpand(operation, context);
AddFields(operation, context);
operation.Parameters.Add(new OpenApiParameter
{

View File

@@ -37,8 +37,52 @@ internal abstract class SwaggerDocumentationFilterBase<TBaseController>
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<TBaseController>
}
});
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<string, OpenApiExample>
{
{ "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<string, OpenApiExample>
{
{ "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]]]") }
}
}
});
}

View File

@@ -15,7 +15,9 @@ internal sealed class SwaggerMediaDocumentationFilter : SwaggerDocumentationFilt
{
operation.Parameters ??= new List<OpenApiParameter>();
AddExpand(operation);
AddExpand(operation, context);
AddFields(operation, context);
AddApiKey(operation);
}

View File

@@ -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<RequestContextOutputExpansionStrategyV2> _logger;
private readonly Stack<Node?> _expandProperties;
private readonly Stack<Node?> _includeProperties;
public RequestContextOutputExpansionStrategyV2(
IHttpContextAccessor httpContextAccessor,
IApiPropertyRenderer propertyRenderer,
ILogger<RequestContextOutputExpansionStrategyV2> logger)
{
_propertyRenderer = propertyRenderer;
_logger = logger;
_expandProperties = new Stack<Node?>();
_includeProperties = new Stack<Node?>();
InitializeExpandAndInclude(httpContextAccessor);
}
public IDictionary<string, object?> 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<string, object?> 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<string, object?>();
}
public IDictionary<string, object?> 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<string, object?> MapProperties(IEnumerable<IPublishedProperty> properties, bool forceExpandProperties = false)
{
Node? currentExpandProperties = _expandProperties.Peek();
if (_expandProperties.Count > 1 && currentExpandProperties is null && forceExpandProperties is false)
{
return new Dictionary<string, object?>();
}
Node? currentIncludeProperties = _includeProperties.Peek();
var result = new Dictionary<string, object?>();
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<Node> 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<Node>();
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;
}
}
}

View File

@@ -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<ControllerActionDescriptor>();
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<ControllerActionDescriptor>();
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<MapToApiVersionAttribute>()?.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<ByIdsContentApiController>(controllerActionDescriptor) || IsControllerType<ByIdsMediaApiController>(controllerActionDescriptor);

View File

@@ -13,5 +13,8 @@
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Umbraco.Tests.UnitTests</_Parameter1>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>DynamicProxyGenAssembly2</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -419,7 +419,7 @@ public static class StringExtensions
/// returns <see langword="false" />.
/// </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;
}

View File

@@ -56,6 +56,12 @@ public interface IPublishedPropertyType
/// </summary>
PropertyCacheLevel DeliveryApiCacheLevel { get; }
/// <summary>
/// Gets the property cache level for Delivery API representation when expanding the property.
/// </summary>
/// <remarks>Defaults to the value of <see cref="DeliveryApiCacheLevel"/>.</remarks>
PropertyCacheLevel DeliveryApiCacheLevelForExpansion => DeliveryApiCacheLevel;
/// <summary>
/// Gets the property model CLR type.
/// </summary>

View File

@@ -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
}
}
/// <inheritdoc />
public PropertyCacheLevel DeliveryApiCacheLevelForExpansion
{
get
{
if (!_initialized)
{
Initialize();
}
return _deliveryApiCacheLevelForExpansion;
}
}
/// <inheritdoc />
public object? ConvertSourceToInter(IPublishedElement owner, object? source, bool preview)
{

View File

@@ -11,6 +11,15 @@ public interface IDeliveryApiPropertyValueConverter : IPropertyValueConverter
/// <returns>The property cache level.</returns>
PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType);
/// <summary>
/// Gets the property cache level for Delivery API representation when expanding the property.
/// </summary>
/// <param name="propertyType">The property type.</param>
/// <returns>The property cache level.</returns>
/// <remarks>Defaults to the value of <see cref="GetDeliveryApiPropertyCacheLevel"/>.</remarks>
PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType)
=> GetDeliveryApiPropertyCacheLevel(propertyType);
/// <summary>
/// Gets the type of values returned by the converter for Delivery API representation.
/// </summary>

View File

@@ -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)

View File

@@ -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<IApiMedia>);
public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview, bool expanding)

View File

@@ -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
{

View File

@@ -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)
{

View File

@@ -52,6 +52,10 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters
public override Type GetPropertyValueType(IPublishedPropertyType propertyType)
=> typeof(BlockGridModel);
/// <inheritdoc />
public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType)
=> PropertyCacheLevel.Element;
/// <inheritdoc />
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
/// <inheritdoc />
public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType);
/// <inheritdoc />
public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot;
/// <inheritdoc />
public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType)
=> typeof(ApiBlockGridModel);

View File

@@ -111,6 +111,9 @@ public class BlockListPropertyValueConverter : PropertyValueConverterBase, IDeli
/// <inheritdoc />
public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType);
/// <inheritdoc />
public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot;
/// <inheritdoc />
public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType)
=> typeof(ApiBlockListModel);

View File

@@ -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<IApiMediaWithCrops>);
public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview, bool expanding)

View File

@@ -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)

View File

@@ -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;