diff --git a/src/Umbraco.Cms.Api.Common/Accessors/RequestContextOutputExpansionStrategyAccessor.cs b/src/Umbraco.Cms.Api.Common/Accessors/RequestContextOutputExpansionStrategyAccessor.cs new file mode 100644 index 000000000000..cb375857b792 --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/Accessors/RequestContextOutputExpansionStrategyAccessor.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Http; +using Umbraco.Cms.Core.DeliveryApi; + +namespace Umbraco.Cms.Api.Common.Accessors; + +public sealed class RequestContextOutputExpansionStrategyAccessor : RequestContextServiceAccessorBase, IOutputExpansionStrategyAccessor +{ + public RequestContextOutputExpansionStrategyAccessor(IHttpContextAccessor httpContextAccessor) + : base(httpContextAccessor) + { + } +} diff --git a/src/Umbraco.Cms.Api.Common/Accessors/RequestContextServiceAccessorBase.cs b/src/Umbraco.Cms.Api.Common/Accessors/RequestContextServiceAccessorBase.cs new file mode 100644 index 000000000000..2748746a964d --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/Accessors/RequestContextServiceAccessorBase.cs @@ -0,0 +1,20 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Umbraco.Cms.Api.Common.Accessors; + +public abstract class RequestContextServiceAccessorBase + where T : class +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + protected RequestContextServiceAccessorBase(IHttpContextAccessor httpContextAccessor) + => _httpContextAccessor = httpContextAccessor; + + public bool TryGetValue([NotNullWhen(true)] out T? requestStartNodeService) + { + requestStartNodeService = _httpContextAccessor.HttpContext?.RequestServices.GetService(); + return requestStartNodeService is not null; + } +} diff --git a/src/Umbraco.Cms.Api.Common/Rendering/ElementOnlyOutputExpansionStrategy.cs b/src/Umbraco.Cms.Api.Common/Rendering/ElementOnlyOutputExpansionStrategy.cs new file mode 100644 index 000000000000..a5f113c7c760 --- /dev/null +++ b/src/Umbraco.Cms.Api.Common/Rendering/ElementOnlyOutputExpansionStrategy.cs @@ -0,0 +1,148 @@ +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Common.Rendering; + +public class ElementOnlyOutputExpansionStrategy : IOutputExpansionStrategy +{ + protected const string All = "$all"; + protected const string None = ""; + protected const string ExpandParameterName = "expand"; + protected const string FieldsParameterName = "fields"; + + private readonly IApiPropertyRenderer _propertyRenderer; + + protected Stack ExpandProperties { get; } = new(); + + protected Stack IncludeProperties { get; } = new(); + + public ElementOnlyOutputExpansionStrategy( + IApiPropertyRenderer propertyRenderer) + { + _propertyRenderer = propertyRenderer; + } + + public virtual 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 virtual 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 virtual IDictionary MapElementProperties(IPublishedElement element) + => MapProperties(element.Properties, true); + + private IDictionary MapProperties(IEnumerable properties, bool forceExpandProperties = false) + { + Node? currentExpandProperties = ExpandProperties.Count > 0 ? ExpandProperties.Peek() : null; + if (ExpandProperties.Count > 1 && currentExpandProperties is null && forceExpandProperties is false) + { + return new Dictionary(); + } + + Node? currentIncludeProperties = IncludeProperties.Count > 0 ? IncludeProperties.Peek() : null; + 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); + + protected sealed 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/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index 7d200398977c..a0860a342234 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -35,28 +35,35 @@ public static IUmbracoBuilder AddDeliveryApi(this IUmbracoBuilder builder) 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) + + builder.Services.AddUnique( + provider => { - return provider.GetRequiredService(); - } + 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(); + }, + ServiceLifetime.Scoped); - // 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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddSingleton(); + + // Webooks register a more basic implementation, remove it. + builder.Services.AddUnique(ServiceLifetime.Singleton); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategyV2.cs b/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategyV2.cs index e1a29b3ec718..779ed31083fe 100644 --- a/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategyV2.cs +++ b/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategyV2.cs @@ -1,62 +1,25 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; +using Umbraco.Cms.Api.Common.Rendering; 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 +internal sealed class RequestContextOutputExpansionStrategyV2 : ElementOnlyOutputExpansionStrategy, 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) + : base(propertyRenderer) { - _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]; @@ -66,7 +29,7 @@ private void InitializeExpandAndInclude(IHttpContextAccessor httpContextAccessor try { - _expandProperties.Push(Node.Parse(toExpand)); + ExpandProperties.Push(Node.Parse(toExpand)); } catch (ArgumentException ex) { @@ -76,7 +39,7 @@ private void InitializeExpandAndInclude(IHttpContextAccessor httpContextAccessor try { - _includeProperties.Push(Node.Parse(toInclude)); + IncludeProperties.Push(Node.Parse(toInclude)); } catch (ArgumentException ex) { @@ -84,102 +47,4 @@ private void InitializeExpandAndInclude(IHttpContextAccessor httpContextAccessor 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 sealed 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.Management/DependencyInjection/WebhooksBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/WebhooksBuilderExtensions.cs index 8d2d20a1d65d..81d764f69793 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/WebhooksBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/WebhooksBuilderExtensions.cs @@ -1,5 +1,9 @@ -using Umbraco.Cms.Api.Management.Factories; +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Api.Common.Accessors; +using Umbraco.Cms.Api.Common.Rendering; +using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.Mapping.Webhook; +using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Extensions; @@ -12,6 +16,10 @@ internal static IUmbracoBuilder AddWebhooks(this IUmbracoBuilder builder) builder.Services.AddUnique(); builder.AddMapDefinition(); + // deliveryApi will overwrite these more basic ones. + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + return builder; } }