diff --git a/global.json b/global.json new file mode 100644 index 0000000000..17a23f4270 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "8.0.100", + "rollForward": "latestFeature", + "allowPrerelease": true + } +} 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.Cms.Persistence.EFCore.Sqlite/Umbraco.Cms.Persistence.EFCore.Sqlite.csproj b/src/Umbraco.Cms.Persistence.EFCore.Sqlite/Umbraco.Cms.Persistence.EFCore.Sqlite.csproj index 5cb9949c04..92eb79375f 100644 --- a/src/Umbraco.Cms.Persistence.EFCore.Sqlite/Umbraco.Cms.Persistence.EFCore.Sqlite.csproj +++ b/src/Umbraco.Cms.Persistence.EFCore.Sqlite/Umbraco.Cms.Persistence.EFCore.Sqlite.csproj @@ -6,6 +6,7 @@ + diff --git a/src/Umbraco.Core/Configuration/Models/WebhookSettings.cs b/src/Umbraco.Core/Configuration/Models/WebhookSettings.cs index b772a103ba..2bfb5a2375 100644 --- a/src/Umbraco.Core/Configuration/Models/WebhookSettings.cs +++ b/src/Umbraco.Core/Configuration/Models/WebhookSettings.cs @@ -7,6 +7,7 @@ public class WebhookSettings { private const bool StaticEnabled = true; private const int StaticMaximumRetries = 5; + internal const string StaticPeriod = "00:00:10"; /// /// Gets or sets a value indicating whether webhooks are enabled. @@ -31,4 +32,10 @@ public class WebhookSettings /// [DefaultValue(StaticMaximumRetries)] public int MaximumRetries { get; set; } = StaticMaximumRetries; + + /// + /// Gets or sets a value for the period of the webhook firing. + /// + [DefaultValue(StaticPeriod)] + public TimeSpan Period { get; set; } = TimeSpan.Parse(StaticPeriod); } diff --git a/src/Umbraco.Core/Constants-UdiEntityType.cs b/src/Umbraco.Core/Constants-UdiEntityType.cs index f65c290516..beae3391b1 100644 --- a/src/Umbraco.Core/Constants-UdiEntityType.cs +++ b/src/Umbraco.Core/Constants-UdiEntityType.cs @@ -46,6 +46,8 @@ public static partial class Constants public const string RelationType = "relation-type"; + public const string Webhook = "webhook"; + // forms public const string FormsForm = "forms-form"; public const string FormsPreValue = "forms-prevalue"; diff --git a/src/Umbraco.Core/Constants-WebhookEvents.cs b/src/Umbraco.Core/Constants-WebhookEvents.cs index 24fe890221..afd57b5188 100644 --- a/src/Umbraco.Core/Constants-WebhookEvents.cs +++ b/src/Umbraco.Core/Constants-WebhookEvents.cs @@ -4,29 +4,55 @@ public static partial class Constants { public static class WebhookEvents { - /// - /// Webhook event name for content publish. - /// - public const string ContentPublish = "ContentPublish"; + public static class Aliases + { + /// + /// Webhook event alias for content publish. + /// + public const string ContentPublish = "Umbraco.ContentPublish"; - /// - /// Webhook event name for content delete. - /// - public const string ContentDelete = "ContentDelete"; + /// + /// Webhook event alias for content delete. + /// + public const string ContentDelete = "Umbraco.ContentDelete"; - /// - /// Webhook event name for content unpublish. - /// - public const string ContentUnpublish = "ContentUnpublish"; + /// + /// Webhook event alias for content unpublish. + /// + public const string ContentUnpublish = "Umbraco.ContentUnpublish"; - /// - /// Webhook event name for media delete. - /// - public const string MediaDelete = "MediaDelete"; + /// + /// Webhook event alias for media delete. + /// + public const string MediaDelete = "Umbraco.MediaDelete"; - /// - /// Webhook event name for media save. - /// - public const string MediaSave = "MediaSave"; + /// + /// Webhook event alias for media save. + /// + public const string MediaSave = "Umbraco.MediaSave"; + } + + public static class Types + { + /// + /// Webhook event type for content. + /// + public const string Content = "Content"; + + /// + /// Webhook event type for content media. + /// + public const string Media = "Media"; + + /// + /// Webhook event type for content member. + /// + public const string Member = "Member"; + + /// + /// Webhook event type for others, this is the default category if you have not chosen one. + /// + public const string Other = "Other"; + } } } diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 2bf71d22b3..fa9fd0da9c 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -42,7 +42,6 @@ using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Core.Telemetry; using Umbraco.Cms.Core.Templates; using Umbraco.Cms.Core.Web; -using Umbraco.Cms.Core.Webhooks; using Umbraco.Extensions; namespace Umbraco.Cms.Core.DependencyInjection @@ -361,9 +360,12 @@ namespace Umbraco.Cms.Core.DependencyInjection // Register filestream security analyzers Services.AddUnique(); Services.AddUnique(); - Services.AddUnique(); + + // Register Webhook services + Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); } } } diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml index fa21b1e377..3e01c0de9b 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/da.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/da.xml @@ -477,9 +477,10 @@ Er du sikker på at du vil forlade Umbraco? Er du sikker? Klip - Rediger ordbogsnøgle - Rediger sprog + Rediger ordbogsnøgle + Rediger sprog Rediger det valgte medie + Edit webhook Indsæt lokalt link Indsæt tegn Indsæt grafisk overskrift @@ -531,6 +532,7 @@ Åben linket i et nyt vindue eller fane Link til medie Vælg startnode for indhold + Vælg event Vælg medie Vælg medietype Vælg ikon @@ -1009,6 +1011,9 @@ Tilføj webhook header Tilføj dokument type Tilføj medie Type + Opret header + Logs + Der er ikke tilføjet nogen webhook headers Culture Code @@ -1463,6 +1468,7 @@ Mange hilsner fra Umbraco robotten Dit systems information er blevet kopieret til udklipsholderen Kunne desværre ikke kopiere dit systems information til udklipsholderen + Webhook gemt Tilføj style diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index 4dc13c1ede..e6662d6201 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -486,9 +486,10 @@ Are you sure? Are you sure? Cut - Edit Dictionary Item - Edit Language + Edit dictionary item + Edit language Edit selected media + Edit webhook Insert local link Insert character Insert graphic headline @@ -542,6 +543,7 @@ Opens the linked document in a new window or tab Link to media Select content start node + Select event Select media Select media type Select icon @@ -1666,6 +1668,7 @@ To manage your website, simply open the Umbraco backoffice and start adding cont Your system information has successfully been copied to the clipboard Could not copy your system information to the clipboard + Webhook saved Add style @@ -1930,6 +1933,15 @@ To manage your website, simply open the Umbraco backoffice and start adding cont Enable cleanup NOTE! The cleanup of historically content versions are disabled globally. These settings will not take effect before it is enabled.]]> + + Create webhook + Add webhook header + Add Document Type + Add Media Type + Create header + Logs + No webhook headers have been added + Add language ISO code diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml index 77272aa79b..0602ff487a 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en_us.xml @@ -501,9 +501,10 @@ Are you sure? Are you sure? Cut - Edit Dictionary Item - Edit Language + Edit dictionary item + Edit language Edit selected media + Edit webhook Insert local link Insert character Insert graphic headline @@ -557,6 +558,7 @@ Opens the linked document in a new window or tab Link to media Select content start node + Select event Select media Select media type Select icon @@ -1731,6 +1733,7 @@ To manage your website, simply open the Umbraco backoffice and start adding cont An error occurred while disabling version cleanup for %0% Your system information has successfully been copied to the clipboard Could not copy your system information to the clipboard + Webhook saved Add style @@ -2015,9 +2018,11 @@ To manage your website, simply open the Umbraco backoffice and start adding cont Create webhook Add webhook header - Logs Add Document Type Add Media Type + Create header + Logs + No webhook headers have been added Add language diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/nl.xml b/src/Umbraco.Core/EmbeddedResources/Lang/nl.xml index 209b5e0d38..5681236051 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/nl.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/nl.xml @@ -1706,6 +1706,13 @@ Echter, Runway biedt een gemakkelijke basis om je snel op weg te helpen. Als je Opschonen aanzetten Geschiedenis opschonen is globaal uitgeschakeld. Deze instellingen worden pas van kracht nadat ze zijn ingeschakeld. + + Webhook aanmaken + Webhook header toevoegen + Logboek + Documenttype toevoegen + Mediatype toevoegen + Taal toevoegen Verplichte taal @@ -1846,6 +1853,7 @@ Echter, Runway biedt een gemakkelijke basis om je snel op weg te helpen. Als je Instellingen Sjabloon Derde partij + Webhooks Nieuwe update beschikbaar 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/Extensions/UdiGetterExtensions.cs b/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs index 03ed07f2fe..fda84c1013 100644 --- a/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs +++ b/src/Umbraco.Core/Extensions/UdiGetterExtensions.cs @@ -358,6 +358,21 @@ public static class UdiGetterExtensions return new GuidUdi(Constants.UdiEntityType.RelationType, entity.Key).EnsureClosed(); } + /// + /// Gets the entity identifier of the entity. + /// + /// The entity. + /// The entity identifier of the entity. + public static GuidUdi GetUdi(this Webhook entity) + { + if (entity == null) + { + throw new ArgumentNullException("entity"); + } + + return new GuidUdi(Constants.UdiEntityType.Webhook, entity.Key).EnsureClosed(); + } + /// /// Gets the entity identifier of the entity. /// diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs index b28cbebaf7..259add3044 100644 --- a/src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs +++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs @@ -61,6 +61,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 2235b50bd4..3dfa220d36 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; @@ -195,9 +196,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); } @@ -247,6 +254,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/Models/WebhookLog.cs b/src/Umbraco.Core/Models/WebhookLog.cs index bd37d79165..e65abdf990 100644 --- a/src/Umbraco.Core/Models/WebhookLog.cs +++ b/src/Umbraco.Core/Models/WebhookLog.cs @@ -14,7 +14,7 @@ public class WebhookLog public DateTime Date { get; set; } - public string EventName { get; set; } = string.Empty; + public string EventAlias { get; set; } = string.Empty; public int RetryCount { get; set; } diff --git a/src/Umbraco.Core/Models/WebhookRequest.cs b/src/Umbraco.Core/Models/WebhookRequest.cs new file mode 100644 index 0000000000..b7f2d58284 --- /dev/null +++ b/src/Umbraco.Core/Models/WebhookRequest.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.Models; + +public class WebhookRequest +{ + public int Id { get; set; } + + public Guid WebhookKey { get; set; } + + public string EventAlias { get; set; } = string.Empty; + + public string? RequestObject { get; set; } + + public int RetryCount { get; set; } +} diff --git a/src/Umbraco.Core/Notifications/WebhookDeletedNotification .cs b/src/Umbraco.Core/Notifications/WebhookDeletedNotification .cs new file mode 100644 index 0000000000..516d52012c --- /dev/null +++ b/src/Umbraco.Core/Notifications/WebhookDeletedNotification .cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +public class WebhookDeletedNotification : DeletedNotification +{ + public WebhookDeletedNotification(Webhook target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/WebhookDeletingNotification.cs b/src/Umbraco.Core/Notifications/WebhookDeletingNotification.cs new file mode 100644 index 0000000000..f703113370 --- /dev/null +++ b/src/Umbraco.Core/Notifications/WebhookDeletingNotification.cs @@ -0,0 +1,17 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +public class WebhookDeletingNotification : DeletingNotification +{ + public WebhookDeletingNotification(Webhook target, EventMessages messages) + : base(target, messages) + { + } + + public WebhookDeletingNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/WebhookSavedNotification.cs b/src/Umbraco.Core/Notifications/WebhookSavedNotification.cs new file mode 100644 index 0000000000..efd4fc3707 --- /dev/null +++ b/src/Umbraco.Core/Notifications/WebhookSavedNotification.cs @@ -0,0 +1,17 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +public class WebhookSavedNotification : SavedNotification +{ + public WebhookSavedNotification(Webhook target, EventMessages messages) + : base(target, messages) + { + } + + public WebhookSavedNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Notifications/WebhookSavingNotification.cs b/src/Umbraco.Core/Notifications/WebhookSavingNotification.cs new file mode 100644 index 0000000000..69dee928c8 --- /dev/null +++ b/src/Umbraco.Core/Notifications/WebhookSavingNotification.cs @@ -0,0 +1,17 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Notifications; + +public class WebhookSavingNotification : SavingNotification +{ + public WebhookSavingNotification(Webhook target, EventMessages messages) + : base(target, messages) + { + } + + public WebhookSavingNotification(IEnumerable target, EventMessages messages) + : base(target, messages) + { + } +} diff --git a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index c56415046f..fead185fd6 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -92,6 +92,7 @@ public static partial class Constants public const string Webhook2Events = Webhook + "2Events"; public const string Webhook2Headers = Webhook + "2Headers"; public const string WebhookLog = Webhook + "Log"; + public const string WebhookRequest = Webhook + "Request"; } } } diff --git a/src/Umbraco.Core/Persistence/Constants-Locks.cs b/src/Umbraco.Core/Persistence/Constants-Locks.cs index e97f16a663..7672d73ec7 100644 --- a/src/Umbraco.Core/Persistence/Constants-Locks.cs +++ b/src/Umbraco.Core/Persistence/Constants-Locks.cs @@ -70,5 +70,10 @@ public static partial class Constants /// ScheduledPublishing job. /// public const int ScheduledPublishing = -341; + + /// + /// ScheduledPublishing job. + /// + public const int WebhookRequest = -342; } } diff --git a/src/Umbraco.Core/Persistence/Repositories/IWebhookRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IWebhookRepository.cs index d045cd172f..3013ee59e0 100644 --- a/src/Umbraco.Core/Persistence/Repositories/IWebhookRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/IWebhookRepository.cs @@ -29,9 +29,11 @@ public interface IWebhookRepository /// /// Gets a webhook by key /// - /// The key of the webhook which will be retrieved. - /// The webhook with the given key. - Task> GetByEventNameAsync(string eventName); + /// The alias of an event, which is referenced by a webhook. + /// + /// A paged model of + /// + Task> GetByAliasAsync(string alias); /// /// Gets a webhook by key diff --git a/src/Umbraco.Core/Persistence/Repositories/IWebhookRequestRepository.cs b/src/Umbraco.Core/Persistence/Repositories/IWebhookRequestRepository.cs new file mode 100644 index 0000000000..1a2b7b158d --- /dev/null +++ b/src/Umbraco.Core/Persistence/Repositories/IWebhookRequestRepository.cs @@ -0,0 +1,33 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Persistence.Repositories; + +public interface IWebhookRequestRepository +{ + /// + /// Creates a webhook request in the current repository. + /// + /// The webhook you want to create. + /// The created webhook + Task CreateAsync(WebhookRequest webhookRequest); + + /// + /// Deletes a webhook request in the current repository + /// + /// The webhook request to be deleted. + /// A representing the asynchronous operation. + Task DeleteAsync(WebhookRequest webhookRequest); + + /// + /// Gets all of the webhook requests in the current repository. + /// + /// A paged model of objects. + Task> GetAllAsync(); + + /// + /// Update a webhook request in the current repository. + /// + /// The webhook request you want to update. + /// The updated webhook + Task UpdateAsync(WebhookRequest webhookRequest); +} diff --git a/src/Umbraco.Core/PropertyEditors/DefaultPropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/DefaultPropertyIndexValueFactory.cs index 28f41c5a20..78fefda4d1 100644 --- a/src/Umbraco.Core/PropertyEditors/DefaultPropertyIndexValueFactory.cs +++ b/src/Umbraco.Core/PropertyEditors/DefaultPropertyIndexValueFactory.cs @@ -9,15 +9,22 @@ namespace Umbraco.Cms.Core.PropertyEditors; /// public class DefaultPropertyIndexValueFactory : IPropertyIndexValueFactory { - /// - public IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published, IEnumerable availableCultures) + public IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published, + IEnumerable availableCultures, IDictionary contentTypeDictionary) { yield return new KeyValuePair>( property.Alias, property.GetValue(culture, segment, published).Yield()); } - [Obsolete("Use the overload with the availableCultures parameter instead, scheduled for removal in v14")] + /// + [Obsolete("Use the non-obsolete overload, scheduled for removal in v14")] + public IEnumerable>> GetIndexValues(IProperty property, string? culture, + string? segment, bool published, IEnumerable availableCultures) + => GetIndexValues(property, culture, segment, published, availableCultures, + new Dictionary()); + + [Obsolete("Use the non-obsolete overload, scheduled for removal in v14")] public IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published) - => GetIndexValues(property, culture, segment, published, Enumerable.Empty()); + => GetIndexValues(property, culture, segment, published, Enumerable.Empty(), new Dictionary()); } 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/IPropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/IPropertyIndexValueFactory.cs index 732644b288..8f8b64a9eb 100644 --- a/src/Umbraco.Core/PropertyEditors/IPropertyIndexValueFactory.cs +++ b/src/Umbraco.Core/PropertyEditors/IPropertyIndexValueFactory.cs @@ -22,9 +22,14 @@ public interface IPropertyIndexValueFactory /// more than one value for a given field. /// /// + IEnumerable>> GetIndexValues(IProperty property, string? culture, + string? segment, bool published, IEnumerable availableCultures, + IDictionary contentTypeDictionary) => GetIndexValues(property, culture, segment, published); + + [Obsolete("Use non-obsolete overload, scheduled for removal in v14")] IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published, IEnumerable availableCultures) => GetIndexValues(property, culture, segment, published); - [Obsolete("Use the overload with the availableCultures parameter instead, scheduled for removal in v14")] + [Obsolete("Use non-obsolete overload, scheduled for removal in v14")] IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published); } diff --git a/src/Umbraco.Core/PropertyEditors/JsonPropertyIndexValueFactoryBase.cs b/src/Umbraco.Core/PropertyEditors/JsonPropertyIndexValueFactoryBase.cs index bf549e2d2e..973ee3d40c 100644 --- a/src/Umbraco.Core/PropertyEditors/JsonPropertyIndexValueFactoryBase.cs +++ b/src/Umbraco.Core/PropertyEditors/JsonPropertyIndexValueFactoryBase.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; @@ -39,13 +40,13 @@ public abstract class JsonPropertyIndexValueFactoryBase : IProperty } - /// public IEnumerable>> GetIndexValues( IProperty property, string? culture, string? segment, bool published, - IEnumerable availableCultures) + IEnumerable availableCultures, + IDictionary contentTypeDictionary) { var result = new List>>(); @@ -63,7 +64,7 @@ public abstract class JsonPropertyIndexValueFactoryBase : IProperty return result; } - result.AddRange(Handle(deserializedPropertyValue, property, culture, segment, published, availableCultures)); + result.AddRange(Handle(deserializedPropertyValue, property, culture, segment, published, availableCultures, contentTypeDictionary)); } catch (InvalidCastException) { @@ -87,9 +88,31 @@ public abstract class JsonPropertyIndexValueFactoryBase : IProperty return summary; } + /// + [Obsolete("Use non-obsolete constructor. This will be removed in Umbraco 14.")] + public IEnumerable>> GetIndexValues( + IProperty property, + string? culture, + string? segment, + bool published, + IEnumerable availableCultures) + => GetIndexValues( + property, + culture, + segment, + published, + Enumerable.Empty(), + StaticServiceProvider.Instance.GetRequiredService().GetAll().ToDictionary(x=>x.Key)); + [Obsolete("Use method overload that has availableCultures, scheduled for removal in v14")] public IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published) - => GetIndexValues(property, culture, segment, published, Enumerable.Empty()); + => GetIndexValues( + property, + culture, + segment, + published, + Enumerable.Empty(), + StaticServiceProvider.Instance.GetRequiredService().GetAll().ToDictionary(x=>x.Key)); /// /// Method to return a list of summary of the content. By default this returns an empty list @@ -104,7 +127,7 @@ public abstract class JsonPropertyIndexValueFactoryBase : IProperty /// /// Method that handle the deserialized object. /// - [Obsolete("Use the overload with the availableCultures parameter instead, scheduled for removal in v14")] + [Obsolete("Use the non-obsolete overload instead, scheduled for removal in v14")] protected abstract IEnumerable>> Handle( TSerialized deserializedPropertyValue, IProperty property, @@ -112,6 +135,15 @@ public abstract class JsonPropertyIndexValueFactoryBase : IProperty string? segment, bool published); + [Obsolete("Use the non-obsolete overload instead, scheduled for removal in v14")] + protected virtual IEnumerable>> Handle( + TSerialized deserializedPropertyValue, + IProperty property, + string? culture, + string? segment, + bool published, + IEnumerable availableCultures) => Handle(deserializedPropertyValue, property, culture, segment, published); + /// /// Method that handle the deserialized object. /// @@ -121,6 +153,7 @@ public abstract class JsonPropertyIndexValueFactoryBase : IProperty string? culture, string? segment, bool published, - IEnumerable availableCultures) => - Handle(deserializedPropertyValue, property, culture, segment, published); + IEnumerable availableCultures, + IDictionary contentTypeDictionary) + => Handle(deserializedPropertyValue, property, culture, segment, published, availableCultures); } diff --git a/src/Umbraco.Core/PropertyEditors/NoopPropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/NoopPropertyIndexValueFactory.cs index 223f8632ff..004138e370 100644 --- a/src/Umbraco.Core/PropertyEditors/NoopPropertyIndexValueFactory.cs +++ b/src/Umbraco.Core/PropertyEditors/NoopPropertyIndexValueFactory.cs @@ -8,6 +8,12 @@ namespace Umbraco.Cms.Core.PropertyEditors; public class NoopPropertyIndexValueFactory : IPropertyIndexValueFactory { /// + public IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published, + IEnumerable availableCultures, IDictionary contentTypeDictionary) + => Array.Empty>>(); + + + [Obsolete("Use the overload with the availableCultures parameter instead, scheduled for removal in v14")] public IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published, IEnumerable availableCultures) => Array.Empty>>(); [Obsolete("Use the overload with the availableCultures parameter instead, scheduled for removal in v14")] 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 48717628d5..a702c813ad 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.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index 3d70bd19dc..b870ebbb02 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -3579,6 +3579,7 @@ public class ContentService : RepositoryService, IContentService Audit(AuditType.Save, userId, content.Id, $"Saved content template: {content.Name}"); scope.Notifications.Publish(new ContentSavedBlueprintNotification(content, evtMsgs)); + scope.Notifications.Publish(new ContentTreeChangeNotification(content, TreeChangeTypes.RefreshNode, evtMsgs)); scope.Complete(); } @@ -3593,6 +3594,7 @@ public class ContentService : RepositoryService, IContentService scope.WriteLock(Constants.Locks.ContentTree); _documentBlueprintRepository.Delete(content); scope.Notifications.Publish(new ContentDeletedBlueprintNotification(content, evtMsgs)); + scope.Notifications.Publish(new ContentTreeChangeNotification(content, TreeChangeTypes.Remove, evtMsgs)); scope.Complete(); } } @@ -3693,6 +3695,7 @@ public class ContentService : RepositoryService, IContentService } scope.Notifications.Publish(new ContentDeletedBlueprintNotification(blueprints, evtMsgs)); + scope.Notifications.Publish(new ContentTreeChangeNotification(blueprints, TreeChangeTypes.Remove, evtMsgs)); scope.Complete(); } } diff --git a/src/Umbraco.Core/Services/IWebhookFiringService.cs b/src/Umbraco.Core/Services/IWebhookFiringService.cs index 0482290c3d..53a631bff3 100644 --- a/src/Umbraco.Core/Services/IWebhookFiringService.cs +++ b/src/Umbraco.Core/Services/IWebhookFiringService.cs @@ -4,5 +4,5 @@ namespace Umbraco.Cms.Core.Services; public interface IWebhookFiringService { - Task FireAsync(Webhook webhook, string eventName, object? payload, CancellationToken cancellationToken); + Task FireAsync(Webhook webhook, string eventAlias, object? payload, CancellationToken cancellationToken); } diff --git a/src/Umbraco.Core/Services/IWebhookLogFactory.cs b/src/Umbraco.Core/Services/IWebhookLogFactory.cs index fa600dda82..3f586f5da4 100644 --- a/src/Umbraco.Core/Services/IWebhookLogFactory.cs +++ b/src/Umbraco.Core/Services/IWebhookLogFactory.cs @@ -5,5 +5,5 @@ namespace Umbraco.Cms.Core.Services; public interface IWebhookLogFactory { - Task CreateAsync(string eventName, WebhookResponseModel responseModel, Webhook webhook, CancellationToken cancellationToken); + Task CreateAsync(string eventAlias, WebhookResponseModel responseModel, Webhook webhook, CancellationToken cancellationToken); } diff --git a/src/Umbraco.Core/Services/IWebhookRequestService.cs b/src/Umbraco.Core/Services/IWebhookRequestService.cs new file mode 100644 index 0000000000..b3b6396b01 --- /dev/null +++ b/src/Umbraco.Core/Services/IWebhookRequestService.cs @@ -0,0 +1,35 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +public interface IWebhookRequestService +{ + /// + /// Creates a webhook request. + /// + /// The key of the webhook. + /// The alias of the event that is creating the request. + /// The payload you want to send with your request. + /// The created webhook + Task CreateAsync(Guid webhookKey, string eventAlias, object? payload); + + /// + /// Gets all of the webhook requests in the current database. + /// + /// An enumerable of objects. + Task> GetAllAsync(); + + /// + /// Deletes a webhook request + /// + /// The webhook request to be deleted. + /// A representing the asynchronous operation. + Task DeleteAsync(WebhookRequest webhookRequest); + + /// + /// Update a webhook request. + /// + /// The webhook request you want to update. + /// The updated webhook + Task UpdateAsync(WebhookRequest webhookRequest); +} diff --git a/src/Umbraco.Core/Services/IWebHookService.cs b/src/Umbraco.Core/Services/IWebhookService.cs similarity index 69% rename from src/Umbraco.Core/Services/IWebHookService.cs rename to src/Umbraco.Core/Services/IWebhookService.cs index 84e5319fe1..657f29df59 100644 --- a/src/Umbraco.Core/Services/IWebHookService.cs +++ b/src/Umbraco.Core/Services/IWebhookService.cs @@ -1,26 +1,27 @@ using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; -public interface IWebHookService +public interface IWebhookService { /// /// Creates a webhook. /// /// to create. - Task CreateAsync(Webhook webhook); + Task> CreateAsync(Webhook webhook); /// /// Updates a webhook. /// /// to update. - Task UpdateAsync(Webhook webhook); + Task> UpdateAsync(Webhook webhook); /// /// Deletes a webhook. /// /// The unique key of the webhook. - Task DeleteAsync(Guid key); + Task> DeleteAsync(Guid key); /// /// Gets a webhook by its key. @@ -36,5 +37,5 @@ public interface IWebHookService /// /// Gets webhooks by event name. /// - Task> GetByEventNameAsync(string eventName); + Task> GetByAliasAsync(string alias); } diff --git a/src/Umbraco.Core/Services/OperationStatus/WebhookOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/WebhookOperationStatus.cs new file mode 100644 index 0000000000..c0514aea69 --- /dev/null +++ b/src/Umbraco.Core/Services/OperationStatus/WebhookOperationStatus.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Services.OperationStatus; + +public enum WebhookOperationStatus +{ + Success, + CancelledByNotification, + NotFound, +} diff --git a/src/Umbraco.Core/Services/WebhookLogFactory.cs b/src/Umbraco.Core/Services/WebhookLogFactory.cs index 22dd75fe84..455bc45e27 100644 --- a/src/Umbraco.Core/Services/WebhookLogFactory.cs +++ b/src/Umbraco.Core/Services/WebhookLogFactory.cs @@ -5,12 +5,12 @@ namespace Umbraco.Cms.Core.Services; public class WebhookLogFactory : IWebhookLogFactory { - public async Task CreateAsync(string eventName, WebhookResponseModel responseModel, Webhook webhook, CancellationToken cancellationToken) + public async Task CreateAsync(string eventAlias, WebhookResponseModel responseModel, Webhook webhook, CancellationToken cancellationToken) { var log = new WebhookLog { Date = DateTime.UtcNow, - EventName = eventName, + EventAlias = eventAlias, Key = Guid.NewGuid(), Url = webhook.Url, WebhookKey = webhook.Key, diff --git a/src/Umbraco.Core/Services/WebhookRequestService.cs b/src/Umbraco.Core/Services/WebhookRequestService.cs new file mode 100644 index 0000000000..249ed21421 --- /dev/null +++ b/src/Umbraco.Core/Services/WebhookRequestService.cs @@ -0,0 +1,63 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Core.Services; + +public class WebhookRequestService : IWebhookRequestService +{ + private readonly ICoreScopeProvider _coreScopeProvider; + private readonly IWebhookRequestRepository _webhookRequestRepository; + private readonly IJsonSerializer _jsonSerializer; + + public WebhookRequestService(ICoreScopeProvider coreScopeProvider, IWebhookRequestRepository webhookRequestRepository, IJsonSerializer jsonSerializer) + { + _coreScopeProvider = coreScopeProvider; + _webhookRequestRepository = webhookRequestRepository; + _jsonSerializer = jsonSerializer; + } + + public async Task CreateAsync(Guid webhookKey, string eventAlias, object? payload) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.WebhookRequest); + var webhookRequest = new WebhookRequest + { + WebhookKey = webhookKey, + EventAlias = eventAlias, + RequestObject = _jsonSerializer.Serialize(payload), + RetryCount = 0, + }; + + webhookRequest = await _webhookRequestRepository.CreateAsync(webhookRequest); + scope.Complete(); + + return webhookRequest; + } + + public Task> GetAllAsync() + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + Task> webhookRequests = _webhookRequestRepository.GetAllAsync(); + scope.Complete(); + return webhookRequests; + } + + public async Task DeleteAsync(WebhookRequest webhookRequest) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.WebhookRequest); + await _webhookRequestRepository.DeleteAsync(webhookRequest); + scope.Complete(); + } + + public async Task UpdateAsync(WebhookRequest webhookRequest) + { + using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); + scope.WriteLock(Constants.Locks.WebhookRequest); + WebhookRequest updated = await _webhookRequestRepository.UpdateAsync(webhookRequest); + scope.Complete(); + return updated; + } +} diff --git a/src/Umbraco.Core/Services/WebhookService.cs b/src/Umbraco.Core/Services/WebhookService.cs index 9813199db8..1f707606b5 100644 --- a/src/Umbraco.Core/Services/WebhookService.cs +++ b/src/Umbraco.Core/Services/WebhookService.cs @@ -1,32 +1,49 @@ +using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; -public class WebhookService : IWebHookService +public class WebhookService : IWebhookService { private readonly ICoreScopeProvider _provider; private readonly IWebhookRepository _webhookRepository; + private readonly IEventMessagesFactory _eventMessagesFactory; - public WebhookService(ICoreScopeProvider provider, IWebhookRepository webhookRepository) + public WebhookService(ICoreScopeProvider provider, IWebhookRepository webhookRepository, IEventMessagesFactory eventMessagesFactory) { _provider = provider; _webhookRepository = webhookRepository; + _eventMessagesFactory = eventMessagesFactory; } /// - public async Task CreateAsync(Webhook webhook) + public async Task> CreateAsync(Webhook webhook) { using ICoreScope scope = _provider.CreateCoreScope(); + + EventMessages eventMessages = _eventMessagesFactory.Get(); + var savingNotification = new WebhookSavingNotification(webhook, eventMessages); + if (await scope.Notifications.PublishCancelableAsync(savingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(WebhookOperationStatus.CancelledByNotification, webhook); + } + Webhook created = await _webhookRepository.CreateAsync(webhook); + + scope.Notifications.Publish(new WebhookSavedNotification(webhook, eventMessages).WithStateFrom(savingNotification)); + scope.Complete(); - return created; + return Attempt.SucceedWithStatus(WebhookOperationStatus.Success, created); } /// - public async Task UpdateAsync(Webhook webhook) + public async Task> UpdateAsync(Webhook webhook) { using ICoreScope scope = _provider.CreateCoreScope(); @@ -34,7 +51,16 @@ public class WebhookService : IWebHookService if (currentWebhook is null) { - throw new ArgumentException("Webhook does not exist"); + scope.Complete(); + return Attempt.FailWithStatus(WebhookOperationStatus.NotFound, webhook); + } + + EventMessages eventMessages = _eventMessagesFactory.Get(); + var savingNotification = new WebhookSavingNotification(webhook, eventMessages); + if (await scope.Notifications.PublishCancelableAsync(savingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(WebhookOperationStatus.CancelledByNotification, webhook); } currentWebhook.Enabled = webhook.Enabled; @@ -44,20 +70,38 @@ public class WebhookService : IWebHookService currentWebhook.Headers = webhook.Headers; await _webhookRepository.UpdateAsync(currentWebhook); + + scope.Notifications.Publish(new WebhookSavedNotification(webhook, eventMessages).WithStateFrom(savingNotification)); + scope.Complete(); + + return Attempt.SucceedWithStatus(WebhookOperationStatus.Success, webhook); } /// - public async Task DeleteAsync(Guid key) + public async Task> DeleteAsync(Guid key) { using ICoreScope scope = _provider.CreateCoreScope(); Webhook? webhook = await _webhookRepository.GetAsync(key); - if (webhook is not null) + if (webhook is null) { - await _webhookRepository.DeleteAsync(webhook); + return Attempt.FailWithStatus(WebhookOperationStatus.NotFound, webhook); } + EventMessages eventMessages = _eventMessagesFactory.Get(); + var deletingNotification = new WebhookDeletingNotification(webhook, eventMessages); + if (await scope.Notifications.PublishCancelableAsync(deletingNotification)) + { + scope.Complete(); + return Attempt.FailWithStatus(WebhookOperationStatus.CancelledByNotification, webhook); + } + + await _webhookRepository.DeleteAsync(webhook); + scope.Notifications.Publish(new WebhookDeletedNotification(webhook, eventMessages).WithStateFrom(deletingNotification)); + scope.Complete(); + + return Attempt.SucceedWithStatus(WebhookOperationStatus.Success, webhook); } /// @@ -80,10 +124,10 @@ public class WebhookService : IWebHookService } /// - public async Task> GetByEventNameAsync(string eventName) + public async Task> GetByAliasAsync(string alias) { using ICoreScope scope = _provider.CreateCoreScope(); - PagedModel webhooks = await _webhookRepository.GetByEventNameAsync(eventName); + PagedModel webhooks = await _webhookRepository.GetByAliasAsync(alias); scope.Complete(); return webhooks.Items; diff --git a/src/Umbraco.Core/UdiParser.cs b/src/Umbraco.Core/UdiParser.cs index 1442d43eb2..40a676d659 100644 --- a/src/Umbraco.Core/UdiParser.cs +++ b/src/Umbraco.Core/UdiParser.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.ComponentModel; using System.Diagnostics.CodeAnalysis; @@ -231,5 +231,6 @@ public sealed class UdiParser { Constants.UdiEntityType.PartialView, UdiType.StringUdi }, { Constants.UdiEntityType.PartialViewMacro, UdiType.StringUdi }, { Constants.UdiEntityType.Stylesheet, UdiType.StringUdi }, + { Constants.UdiEntityType.Webhook, UdiType.GuidUdi }, }; } diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 4f4dd097b1..83491bbe6c 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -19,6 +19,13 @@ + + + + + + + <_Parameter1>Umbraco.Tests diff --git a/src/Umbraco.Core/Webhooks/Events/ContentDeleteWebhookEvent.cs b/src/Umbraco.Core/Webhooks/Events/ContentDeleteWebhookEvent.cs index 52b8d233e5..85a1f39ba9 100644 --- a/src/Umbraco.Core/Webhooks/Events/ContentDeleteWebhookEvent.cs +++ b/src/Umbraco.Core/Webhooks/Events/ContentDeleteWebhookEvent.cs @@ -7,22 +7,24 @@ using Umbraco.Cms.Core.Sync; namespace Umbraco.Cms.Core.Webhooks.Events; +[WebhookEvent("Content was deleted", Constants.WebhookEvents.Types.Content)] public class ContentDeleteWebhookEvent : WebhookEventContentBase { public ContentDeleteWebhookEvent( IWebhookFiringService webhookFiringService, - IWebHookService webHookService, + IWebhookService webhookService, IOptionsMonitor webhookSettings, IServerRoleAccessor serverRoleAccessor) : base( webhookFiringService, - webHookService, + webhookService, webhookSettings, - serverRoleAccessor, - Constants.WebhookEvents.ContentDelete) + serverRoleAccessor) { } + public override string Alias => Constants.WebhookEvents.Aliases.ContentUnpublish; + protected override IEnumerable GetEntitiesFromNotification(ContentDeletedNotification notification) => notification.DeletedEntities; diff --git a/src/Umbraco.Core/Webhooks/Events/ContentPublishWebhookEvent.cs b/src/Umbraco.Core/Webhooks/Events/ContentPublishWebhookEvent.cs index 8f308432b8..e1ba7125ec 100644 --- a/src/Umbraco.Core/Webhooks/Events/ContentPublishWebhookEvent.cs +++ b/src/Umbraco.Core/Webhooks/Events/ContentPublishWebhookEvent.cs @@ -10,6 +10,7 @@ using Umbraco.Cms.Core.Sync; namespace Umbraco.Cms.Core.Webhooks.Events; +[WebhookEvent("Content was published", Constants.WebhookEvents.Types.Content)] public class ContentPublishWebhookEvent : WebhookEventContentBase { private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; @@ -17,22 +18,23 @@ public class ContentPublishWebhookEvent : WebhookEventContentBase webhookSettings, IServerRoleAccessor serverRoleAccessor, IPublishedSnapshotAccessor publishedSnapshotAccessor, IApiContentBuilder apiContentBuilder) : base( webhookFiringService, - webHookService, + webhookService, webhookSettings, - serverRoleAccessor, - Constants.WebhookEvents.ContentPublish) + serverRoleAccessor) { _publishedSnapshotAccessor = publishedSnapshotAccessor; _apiContentBuilder = apiContentBuilder; } + public override string Alias => Constants.WebhookEvents.Aliases.ContentPublish; + protected override IEnumerable GetEntitiesFromNotification(ContentPublishedNotification notification) => notification.PublishedEntities; protected override object? ConvertEntityToRequestPayload(IContent entity) diff --git a/src/Umbraco.Core/Webhooks/Events/ContentUnpublishWebhookEvent.cs b/src/Umbraco.Core/Webhooks/Events/ContentUnpublishWebhookEvent.cs index c8a8fd789e..76499eb277 100644 --- a/src/Umbraco.Core/Webhooks/Events/ContentUnpublishWebhookEvent.cs +++ b/src/Umbraco.Core/Webhooks/Events/ContentUnpublishWebhookEvent.cs @@ -7,22 +7,24 @@ using Umbraco.Cms.Core.Sync; namespace Umbraco.Cms.Core.Webhooks.Events; +[WebhookEvent("Content was unpublished", Constants.WebhookEvents.Types.Content)] public class ContentUnpublishWebhookEvent : WebhookEventContentBase { public ContentUnpublishWebhookEvent( IWebhookFiringService webhookFiringService, - IWebHookService webHookService, + IWebhookService webhookService, IOptionsMonitor webhookSettings, IServerRoleAccessor serverRoleAccessor) : base( webhookFiringService, - webHookService, + webhookService, webhookSettings, - serverRoleAccessor, - Constants.WebhookEvents.ContentUnpublish) + serverRoleAccessor) { } + public override string Alias => Constants.WebhookEvents.Aliases.ContentDelete; + protected override IEnumerable GetEntitiesFromNotification(ContentUnpublishedNotification notification) => notification.UnpublishedEntities; protected override object ConvertEntityToRequestPayload(IContent entity) => new DefaultPayloadModel { Id = entity.Key }; diff --git a/src/Umbraco.Core/Webhooks/Events/MediaDeleteWebhookEvent.cs b/src/Umbraco.Core/Webhooks/Events/MediaDeleteWebhookEvent.cs index eb19e3e888..ba9fb2333a 100644 --- a/src/Umbraco.Core/Webhooks/Events/MediaDeleteWebhookEvent.cs +++ b/src/Umbraco.Core/Webhooks/Events/MediaDeleteWebhookEvent.cs @@ -7,22 +7,24 @@ using Umbraco.Cms.Core.Sync; namespace Umbraco.Cms.Core.Webhooks.Events; +[WebhookEvent("Media was deleted", Constants.WebhookEvents.Types.Media)] public class MediaDeleteWebhookEvent : WebhookEventContentBase { public MediaDeleteWebhookEvent( IWebhookFiringService webhookFiringService, - IWebHookService webHookService, + IWebhookService webhookService, IOptionsMonitor webhookSettings, IServerRoleAccessor serverRoleAccessor) : base( webhookFiringService, - webHookService, + webhookService, webhookSettings, - serverRoleAccessor, - Constants.WebhookEvents.MediaDelete) + serverRoleAccessor) { } + public override string Alias => Constants.WebhookEvents.Aliases.MediaDelete; + protected override IEnumerable GetEntitiesFromNotification(MediaDeletedNotification notification) => notification.DeletedEntities; protected override object ConvertEntityToRequestPayload(IMedia entity) => new DefaultPayloadModel { Id = entity.Key }; diff --git a/src/Umbraco.Core/Webhooks/Events/MediaSaveWebhookEvent.cs b/src/Umbraco.Core/Webhooks/Events/MediaSaveWebhookEvent.cs index 9a7dcaa3d5..cea7181d51 100644 --- a/src/Umbraco.Core/Webhooks/Events/MediaSaveWebhookEvent.cs +++ b/src/Umbraco.Core/Webhooks/Events/MediaSaveWebhookEvent.cs @@ -10,6 +10,7 @@ using Umbraco.Cms.Core.Sync; namespace Umbraco.Cms.Core.Webhooks.Events; +[WebhookEvent("Media was saved", Constants.WebhookEvents.Types.Media)] public class MediaSaveWebhookEvent : WebhookEventContentBase { private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; @@ -17,22 +18,23 @@ public class MediaSaveWebhookEvent : WebhookEventContentBase webhookSettings, IServerRoleAccessor serverRoleAccessor, IPublishedSnapshotAccessor publishedSnapshotAccessor, IApiMediaBuilder apiMediaBuilder) : base( webhookFiringService, - webHookService, + webhookService, webhookSettings, - serverRoleAccessor, - Constants.WebhookEvents.MediaSave) + serverRoleAccessor) { _publishedSnapshotAccessor = publishedSnapshotAccessor; _apiMediaBuilder = apiMediaBuilder; } + public override string Alias => Constants.WebhookEvents.Aliases.MediaSave; + protected override IEnumerable GetEntitiesFromNotification(MediaSavedNotification notification) => notification.SavedEntities; protected override object? ConvertEntityToRequestPayload(IMedia entity) diff --git a/src/Umbraco.Core/Webhooks/IWebhookEvent.cs b/src/Umbraco.Core/Webhooks/IWebhookEvent.cs index 954055d104..693cc22193 100644 --- a/src/Umbraco.Core/Webhooks/IWebhookEvent.cs +++ b/src/Umbraco.Core/Webhooks/IWebhookEvent.cs @@ -3,4 +3,8 @@ public interface IWebhookEvent { string EventName { get; } + + string EventType { get; } + + string Alias { get; } } diff --git a/src/Umbraco.Core/Webhooks/WebhookEventAttribute.cs b/src/Umbraco.Core/Webhooks/WebhookEventAttribute.cs new file mode 100644 index 0000000000..3c9015e8bf --- /dev/null +++ b/src/Umbraco.Core/Webhooks/WebhookEventAttribute.cs @@ -0,0 +1,26 @@ +namespace Umbraco.Cms.Core.Webhooks; + +[AttributeUsage(AttributeTargets.Class)] +public class WebhookEventAttribute : Attribute +{ + public WebhookEventAttribute(string name) + : this(name, Constants.WebhookEvents.Types.Other) + { + } + + public WebhookEventAttribute(string name, string eventType) + { + Name = name; + EventType = eventType; + } + + /// + /// Gets the friendly name of the event. + /// + public string? Name { get; } + + /// + /// Gets the type of event. + /// + public string? EventType { get; } +} diff --git a/src/Umbraco.Core/Webhooks/WebhookEventBase.cs b/src/Umbraco.Core/Webhooks/WebhookEventBase.cs index 8753eeecf9..529fe4191b 100644 --- a/src/Umbraco.Core/Webhooks/WebhookEventBase.cs +++ b/src/Umbraco.Core/Webhooks/WebhookEventBase.cs @@ -6,6 +6,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Sync; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.Webhooks; @@ -14,26 +15,37 @@ public abstract class WebhookEventBase : IWebhookEvent, INotifica { private readonly IServerRoleAccessor _serverRoleAccessor; - /// + public abstract string Alias { get; } + public string EventName { get; set; } + public string EventType { get; } + protected IWebhookFiringService WebhookFiringService { get; } - protected IWebHookService WebHookService { get; } + + protected IWebhookService WebhookService { get; } + protected WebhookSettings WebhookSettings { get; private set; } + + protected WebhookEventBase( IWebhookFiringService webhookFiringService, - IWebHookService webHookService, + IWebhookService webhookService, IOptionsMonitor webhookSettings, - IServerRoleAccessor serverRoleAccessor, - string eventName) + IServerRoleAccessor serverRoleAccessor) { - EventName = eventName; WebhookFiringService = webhookFiringService; - WebHookService = webHookService; + WebhookService = webhookService; _serverRoleAccessor = serverRoleAccessor; + // assign properties based on the attribute, if it is found + WebhookEventAttribute? attribute = GetType().GetCustomAttribute(false); + + EventType = attribute?.EventType ?? "Others"; + EventName = attribute?.Name ?? Alias; + WebhookSettings = webhookSettings.CurrentValue; webhookSettings.OnChange(x => WebhookSettings = x); } @@ -50,7 +62,7 @@ public abstract class WebhookEventBase : IWebhookEvent, INotifica continue; } - await WebhookFiringService.FireAsync(webhook, EventName, notification, cancellationToken); + await WebhookFiringService.FireAsync(webhook, Alias, notification, cancellationToken); } } @@ -79,7 +91,7 @@ public abstract class WebhookEventBase : IWebhookEvent, INotifica return; } - IEnumerable webhooks = await WebHookService.GetByEventNameAsync(EventName); + IEnumerable webhooks = await WebhookService.GetByAliasAsync(Alias); await ProcessWebhooks(notification, webhooks, cancellationToken); } diff --git a/src/Umbraco.Core/Webhooks/WebhookEventContentBase.cs b/src/Umbraco.Core/Webhooks/WebhookEventContentBase.cs index 5b8b8c626e..6b72bbaacb 100644 --- a/src/Umbraco.Core/Webhooks/WebhookEventContentBase.cs +++ b/src/Umbraco.Core/Webhooks/WebhookEventContentBase.cs @@ -14,11 +14,10 @@ public abstract class WebhookEventContentBase : WebhookE { protected WebhookEventContentBase( IWebhookFiringService webhookFiringService, - IWebHookService webHookService, + IWebhookService webhookService, IOptionsMonitor webhookSettings, - IServerRoleAccessor serverRoleAccessor, - string eventName) - : base(webhookFiringService, webHookService, webhookSettings, serverRoleAccessor, eventName) + IServerRoleAccessor serverRoleAccessor) + : base(webhookFiringService, webhookService, webhookSettings, serverRoleAccessor) { } @@ -38,7 +37,7 @@ public abstract class WebhookEventContentBase : WebhookE continue; } - await WebhookFiringService.FireAsync(webhook, EventName, ConvertEntityToRequestPayload(entity), cancellationToken); + await WebhookFiringService.FireAsync(webhook, Alias, ConvertEntityToRequestPayload(entity), cancellationToken); } } } diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/DelayCalculator.cs b/src/Umbraco.Infrastructure/BackgroundJobs/DelayCalculator.cs new file mode 100644 index 0000000000..42d016c066 --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/DelayCalculator.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Configuration; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs +{ + public class DelayCalculator + { + /// + /// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal + /// configuration for the first run time is available. + /// + /// The configured time to first run the task in crontab format. + /// An instance of + /// The logger. + /// The default delay to use when a first run time is not configured. + /// The delay before first running the recurring task. + public static TimeSpan GetDelay( + string firstRunTime, + ICronTabParser cronTabParser, + ILogger logger, + TimeSpan defaultDelay) => GetDelay(firstRunTime, cronTabParser, logger, DateTime.Now, defaultDelay); + + /// + /// Determines the delay before the first run of a recurring task implemented as a hosted service when an optonal + /// configuration for the first run time is available. + /// + /// The configured time to first run the task in crontab format. + /// An instance of + /// The logger. + /// The current datetime. + /// The default delay to use when a first run time is not configured. + /// The delay before first running the recurring task. + /// Internal to expose for unit tests. + internal static TimeSpan GetDelay( + string firstRunTime, + ICronTabParser cronTabParser, + ILogger logger, + DateTime now, + TimeSpan defaultDelay) + { + // If first run time not set, start with just small delay after application start. + if (string.IsNullOrEmpty(firstRunTime)) + { + return defaultDelay; + } + + // If first run time not a valid cron tab, log, and revert to small delay after application start. + if (!cronTabParser.IsValidCronTab(firstRunTime)) + { + logger.LogWarning("Could not parse {FirstRunTime} as a crontab expression. Defaulting to default delay for hosted service start.", firstRunTime); + return defaultDelay; + } + + // Otherwise start at scheduled time according to cron expression, unless within the default delay period. + DateTime firstRunOccurance = cronTabParser.GetNextOccurrence(firstRunTime, now); + TimeSpan delay = firstRunOccurance - now; + return delay < defaultDelay + ? defaultDelay + : delay; + } + } +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/IRecurringBackgroundJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/IRecurringBackgroundJob.cs new file mode 100644 index 0000000000..c6be3dcec5 --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/IRecurringBackgroundJob.cs @@ -0,0 +1,28 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs; +/// +/// A recurring background job +/// +public interface IRecurringBackgroundJob +{ + static readonly TimeSpan DefaultDelay = System.TimeSpan.FromMinutes(3); + static readonly ServerRole[] DefaultServerRoles = new[] { ServerRole.Single, ServerRole.SchedulingPublisher }; + + /// Timespan representing how often the task should recur. + TimeSpan Period { get; } + + /// + /// Timespan representing the initial delay after application start-up before the first run of the task + /// occurs. + /// + TimeSpan Delay { get => DefaultDelay; } + + ServerRole[] ServerRoles { get => DefaultServerRoles; } + + event EventHandler PeriodChanged; + + Task RunJobAsync(); +} + diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ContentVersionCleanupJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ContentVersionCleanupJob.cs new file mode 100644 index 0000000000..cb89d600aa --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ContentVersionCleanupJob.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.BackgroundJobs; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; + +/// +/// Recurring hosted service that executes the content history cleanup. +/// +public class ContentVersionCleanupJob : IRecurringBackgroundJob +{ + + public TimeSpan Period { get => TimeSpan.FromHours(1); } + + // No-op event as the period never changes on this job + public event EventHandler PeriodChanged { add { } remove { } } + + private readonly ILogger _logger; + private readonly IContentVersionService _service; + private readonly IOptionsMonitor _settingsMonitor; + + + /// + /// Initializes a new instance of the class. + /// + public ContentVersionCleanupJob( + ILogger logger, + IOptionsMonitor settingsMonitor, + IContentVersionService service) + { + _logger = logger; + _settingsMonitor = settingsMonitor; + _service = service; + } + + /// + public Task RunJobAsync() + { + // Globally disabled by feature flag + if (!_settingsMonitor.CurrentValue.ContentVersionCleanupPolicy.EnableCleanup) + { + _logger.LogInformation( + "ContentVersionCleanup task will not run as it has been globally disabled via configuration"); + return Task.CompletedTask; + } + + + var count = _service.PerformContentVersionCleanup(DateTime.Now).Count; + + if (count > 0) + { + _logger.LogInformation("Deleted {count} ContentVersion(s)", count); + } + else + { + _logger.LogDebug("Task complete, no items were Deleted"); + } + + return Task.CompletedTask; + } +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJob.cs new file mode 100644 index 0000000000..ba78af33b4 --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJob.cs @@ -0,0 +1,116 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.HealthChecks; +using Umbraco.Cms.Core.HealthChecks.NotificationMethods; +using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; + +/// +/// Hosted service implementation for recurring health check notifications. +/// +public class HealthCheckNotifierJob : IRecurringBackgroundJob +{ + + + public TimeSpan Period { get; private set; } + public TimeSpan Delay { get; private set; } + + private event EventHandler? _periodChanged; + public event EventHandler PeriodChanged + { + add { _periodChanged += value; } + remove { _periodChanged -= value; } + } + + private readonly HealthCheckCollection _healthChecks; + private readonly ILogger _logger; + private readonly HealthCheckNotificationMethodCollection _notifications; + private readonly IProfilingLogger _profilingLogger; + private readonly ICoreScopeProvider _scopeProvider; + private HealthChecksSettings _healthChecksSettings; + + /// + /// Initializes a new instance of the class. + /// + /// The configuration for health check settings. + /// The collection of healthchecks. + /// The collection of healthcheck notification methods. + /// Provides scopes for database operations. + /// The typed logger. + /// The profiling logger. + /// Parser of crontab expressions. + public HealthCheckNotifierJob( + IOptionsMonitor healthChecksSettings, + HealthCheckCollection healthChecks, + HealthCheckNotificationMethodCollection notifications, + ICoreScopeProvider scopeProvider, + ILogger logger, + IProfilingLogger profilingLogger, + ICronTabParser cronTabParser) + { + _healthChecksSettings = healthChecksSettings.CurrentValue; + _healthChecks = healthChecks; + _notifications = notifications; + _scopeProvider = scopeProvider; + _logger = logger; + _profilingLogger = profilingLogger; + + Period = healthChecksSettings.CurrentValue.Notification.Period; + Delay = DelayCalculator.GetDelay(healthChecksSettings.CurrentValue.Notification.FirstRunTime, cronTabParser, logger, TimeSpan.FromMinutes(3)); + + + healthChecksSettings.OnChange(x => + { + _healthChecksSettings = x; + Period = x.Notification.Period; + _periodChanged?.Invoke(this, EventArgs.Empty); + }); + } + + public async Task RunJobAsync() + { + if (_healthChecksSettings.Notification.Enabled == false) + { + return; + } + + // Ensure we use an explicit scope since we are running on a background thread and plugin health + // checks can be making service/database calls so we want to ensure the CallContext/Ambient scope + // isn't used since that can be problematic. + using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true)) + using (_profilingLogger.DebugDuration("Health checks executing", "Health checks complete")) + { + // Don't notify for any checks that are disabled, nor for any disabled just for notifications. + Guid[] disabledCheckIds = _healthChecksSettings.Notification.DisabledChecks + .Select(x => x.Id) + .Union(_healthChecksSettings.DisabledChecks + .Select(x => x.Id)) + .Distinct() + .ToArray(); + + IEnumerable checks = _healthChecks + .Where(x => disabledCheckIds.Contains(x.Id) == false); + + HealthCheckResults results = await HealthCheckResults.Create(checks); + results.LogResults(); + + // Send using registered notification methods that are enabled. + foreach (IHealthCheckNotificationMethod notificationMethod in _notifications.Where(x => x.Enabled)) + { + await notificationMethod.SendAsync(results); + } + } + } +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/KeepAliveJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/KeepAliveJob.cs new file mode 100644 index 0000000000..a9849ddbb7 --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/KeepAliveJob.cs @@ -0,0 +1,90 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Routing; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Core.Sync; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; + +/// +/// Hosted service implementation for keep alive feature. +/// +public class KeepAliveJob : IRecurringBackgroundJob +{ + public TimeSpan Period { get => TimeSpan.FromMinutes(5); } + + // No-op event as the period never changes on this job + public event EventHandler PeriodChanged { add { } remove { } } + + private readonly IHostingEnvironment _hostingEnvironment; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly IProfilingLogger _profilingLogger; + private KeepAliveSettings _keepAliveSettings; + + /// + /// Initializes a new instance of the class. + /// + /// The current hosting environment + /// The configuration for keep alive settings. + /// The typed logger. + /// The profiling logger. + /// Factory for instances. + public KeepAliveJob( + IHostingEnvironment hostingEnvironment, + IOptionsMonitor keepAliveSettings, + ILogger logger, + IProfilingLogger profilingLogger, + IHttpClientFactory httpClientFactory) + { + _hostingEnvironment = hostingEnvironment; + _keepAliveSettings = keepAliveSettings.CurrentValue; + _logger = logger; + _profilingLogger = profilingLogger; + _httpClientFactory = httpClientFactory; + + keepAliveSettings.OnChange(x => _keepAliveSettings = x); + } + + public async Task RunJobAsync() + { + if (_keepAliveSettings.DisableKeepAliveTask) + { + return; + } + + using (_profilingLogger.DebugDuration("Keep alive executing", "Keep alive complete")) + { + var umbracoAppUrl = _hostingEnvironment.ApplicationMainUrl?.ToString(); + if (umbracoAppUrl.IsNullOrWhiteSpace()) + { + _logger.LogWarning("No umbracoApplicationUrl for service (yet), skip."); + return; + } + + // If the config is an absolute path, just use it + var keepAlivePingUrl = WebPath.Combine( + umbracoAppUrl!, + _hostingEnvironment.ToAbsolute(_keepAliveSettings.KeepAlivePingUrl)); + + try + { + var request = new HttpRequestMessage(HttpMethod.Get, keepAlivePingUrl); + HttpClient httpClient = _httpClientFactory.CreateClient(Constants.HttpClients.IgnoreCertificateErrors); + _ = await httpClient.SendAsync(request); + } + catch (Exception ex) + { + _logger.LogError(ex, "Keep alive failed (at '{keepAlivePingUrl}').", keepAlivePingUrl); + } + } + } +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJob.cs new file mode 100644 index 0000000000..1c745661cb --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJob.cs @@ -0,0 +1,73 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; + +/// +/// Log scrubbing hosted service. +/// +/// +/// Will only run on non-replica servers. +/// +public class LogScrubberJob : IRecurringBackgroundJob +{ + public TimeSpan Period { get => TimeSpan.FromHours(4); } + + // No-op event as the period never changes on this job + public event EventHandler PeriodChanged { add { } remove { } } + + + private readonly IAuditService _auditService; + private readonly ILogger _logger; + private readonly IProfilingLogger _profilingLogger; + private readonly ICoreScopeProvider _scopeProvider; + private LoggingSettings _settings; + + /// + /// Initializes a new instance of the class. + /// + /// Service for handling audit operations. + /// The configuration for logging settings. + /// Provides scopes for database operations. + /// The typed logger. + /// The profiling logger. + public LogScrubberJob( + IAuditService auditService, + IOptionsMonitor settings, + ICoreScopeProvider scopeProvider, + ILogger logger, + IProfilingLogger profilingLogger) + { + + _auditService = auditService; + _settings = settings.CurrentValue; + _scopeProvider = scopeProvider; + _logger = logger; + _profilingLogger = profilingLogger; + settings.OnChange(x => _settings = x); + } + + public Task RunJobAsync() + { + + // Ensure we use an explicit scope since we are running on a background thread. + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) + using (_profilingLogger.DebugDuration("Log scrubbing executing", "Log scrubbing complete")) + { + _auditService.CleanLogs((int)_settings.MaxLogAge.TotalMinutes); + _ = scope.Complete(); + } + + return Task.CompletedTask; + } +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ReportSiteJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ReportSiteJob.cs new file mode 100644 index 0000000000..5d39b57add --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ReportSiteJob.cs @@ -0,0 +1,92 @@ +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Core.Telemetry; +using Umbraco.Cms.Core.Telemetry.Models; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; + +public class ReportSiteJob : IRecurringBackgroundJob +{ + + public TimeSpan Period { get => TimeSpan.FromDays(1); } + public TimeSpan Delay { get => TimeSpan.FromMinutes(5); } + public ServerRole[] ServerRoles { get => Enum.GetValues(); } + + // No-op event as the period never changes on this job + public event EventHandler PeriodChanged { add { } remove { } } + + + private static HttpClient _httpClient = new(); + private readonly ILogger _logger; + private readonly ITelemetryService _telemetryService; + + + public ReportSiteJob( + ILogger logger, + ITelemetryService telemetryService) + { + _logger = logger; + _telemetryService = telemetryService; + _httpClient = new HttpClient(); + } + + /// + /// Runs the background task to send the anonymous ID + /// to telemetry service + /// + public async Task RunJobAsync() + { + + if (_telemetryService.TryGetTelemetryReportData(out TelemetryReportData? telemetryReportData) is false) + { + _logger.LogWarning("No telemetry marker found"); + + return; + } + + try + { + if (_httpClient.BaseAddress is null) + { + // Send data to LIVE telemetry + _httpClient.BaseAddress = new Uri("https://telemetry.umbraco.com/"); + +#if DEBUG + // Send data to DEBUG telemetry service + _httpClient.BaseAddress = new Uri("https://telemetry.rainbowsrock.net/"); +#endif + } + + _httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Content-Type", "application/json"); + + using (var request = new HttpRequestMessage(HttpMethod.Post, "installs/")) + { + request.Content = new StringContent(JsonConvert.SerializeObject(telemetryReportData), Encoding.UTF8, + "application/json"); + + // Make a HTTP Post to telemetry service + // https://telemetry.umbraco.com/installs/ + // Fire & Forget, do not need to know if its a 200, 500 etc + using (await _httpClient.SendAsync(request)) + { + } + } + } + catch + { + // Silently swallow + // The user does not need the logs being polluted if our service has fallen over or is down etc + // Hence only logging this at a more verbose level (which users should not be using in production) + _logger.LogDebug("There was a problem sending a request to the Umbraco telemetry service"); + } + } +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJob.cs new file mode 100644 index 0000000000..f815366a21 --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJob.cs @@ -0,0 +1,105 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Core.Web; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; + +/// +/// Hosted service implementation for scheduled publishing feature. +/// +/// +/// Runs only on non-replica servers. +/// +public class ScheduledPublishingJob : IRecurringBackgroundJob +{ + public TimeSpan Period { get => TimeSpan.FromMinutes(1); } + // No-op event as the period never changes on this job + public event EventHandler PeriodChanged { add { } remove { } } + + + private readonly IContentService _contentService; + private readonly ILogger _logger; + private readonly ICoreScopeProvider _scopeProvider; + private readonly IServerMessenger _serverMessenger; + private readonly IUmbracoContextFactory _umbracoContextFactory; + + /// + /// Initializes a new instance of the class. + /// + public ScheduledPublishingJob( + IContentService contentService, + IUmbracoContextFactory umbracoContextFactory, + ILogger logger, + IServerMessenger serverMessenger, + ICoreScopeProvider scopeProvider) + { + _contentService = contentService; + _umbracoContextFactory = umbracoContextFactory; + _logger = logger; + _serverMessenger = serverMessenger; + _scopeProvider = scopeProvider; + } + + public Task RunJobAsync() + { + if (Suspendable.ScheduledPublishing.CanRun == false) + { + return Task.CompletedTask; + } + + try + { + // Ensure we run with an UmbracoContext, because this will run in a background task, + // and developers may be using the UmbracoContext in the event handlers. + + // TODO: or maybe not, CacheRefresherComponent already ensures a context when handling events + // - UmbracoContext 'current' needs to be refactored and cleaned up + // - batched messenger should not depend on a current HttpContext + // but then what should be its "scope"? could we attach it to scopes? + // - and we should definitively *not* have to flush it here (should be auto) + using UmbracoContextReference contextReference = _umbracoContextFactory.EnsureUmbracoContext(); + using ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true); + + /* We used to assume that there will never be two instances running concurrently where (IsMainDom && ServerRole == SchedulingPublisher) + * However this is possible during an azure deployment slot swap for the SchedulingPublisher instance when trying to achieve zero downtime deployments. + * If we take a distributed write lock, we are certain that the multiple instances of the job will not run in parallel. + * It's possible that during the swapping process we may run this job more frequently than intended but this is not of great concern and it's + * only until the old SchedulingPublisher shuts down. */ + scope.EagerWriteLock(Constants.Locks.ScheduledPublishing); + try + { + // Run + IEnumerable result = _contentService.PerformScheduledPublish(DateTime.Now); + foreach (IGrouping grouped in result.GroupBy(x => x.Result)) + { + _logger.LogInformation( + "Scheduled publishing result: '{StatusCount}' items with status {Status}", + grouped.Count(), + grouped.Key); + } + } + finally + { + // If running on a temp context, we have to flush the messenger + if (contextReference.IsRoot) + { + _serverMessenger.SendMessages(); + } + } + } + catch (Exception ex) + { + // important to catch *everything* to ensure the task repeats + _logger.LogError(ex, "Failed."); + } + + return Task.CompletedTask; + } +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJob.cs new file mode 100644 index 0000000000..cc6e35bf83 --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJob.cs @@ -0,0 +1,59 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration; + +/// +/// Implements periodic database instruction processing as a hosted service. +/// +public class InstructionProcessJob : IRecurringBackgroundJob +{ + + public TimeSpan Period { get; } + public TimeSpan Delay { get => TimeSpan.FromMinutes(1); } + public ServerRole[] ServerRoles { get => Enum.GetValues(); } + + // No-op event as the period never changes on this job + public event EventHandler PeriodChanged { add { } remove { } } + + private readonly ILogger _logger; + private readonly IServerMessenger _messenger; + + /// + /// Initializes a new instance of the class. + /// + /// Service broadcasting cache notifications to registered servers. + /// The typed logger. + /// The configuration for global settings. + public InstructionProcessJob( + IServerMessenger messenger, + ILogger logger, + IOptions globalSettings) + { + _messenger = messenger; + _logger = logger; + + Period = globalSettings.Value.DatabaseServerMessenger.TimeBetweenSyncOperations; + } + + public Task RunJobAsync() + { + try + { + _messenger.Sync(); + } + catch (Exception e) + { + _logger.LogError(e, "Failed (will repeat)."); + } + + return Task.CompletedTask; + } +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJob.cs new file mode 100644 index 0000000000..8258da1a35 --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJob.cs @@ -0,0 +1,102 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration; + +/// +/// Implements periodic server "touching" (to mark as active/deactive) as a hosted service. +/// +public class TouchServerJob : IRecurringBackgroundJob +{ + public TimeSpan Period { get; private set; } + public TimeSpan Delay { get => TimeSpan.FromSeconds(15); } + + // Runs on all servers + public ServerRole[] ServerRoles { get => Enum.GetValues(); } + + private event EventHandler? _periodChanged; + public event EventHandler PeriodChanged { + add { _periodChanged += value; } + remove { _periodChanged -= value; } + } + + + private readonly IHostingEnvironment _hostingEnvironment; + private readonly ILogger _logger; + private readonly IServerRegistrationService _serverRegistrationService; + private readonly IServerRoleAccessor _serverRoleAccessor; + private GlobalSettings _globalSettings; + + /// + /// Initializes a new instance of the class. + /// + /// Services for server registrations. + /// The typed logger. + /// The configuration for global settings. + /// The hostingEnviroment. + /// The accessor for the server role + public TouchServerJob( + IServerRegistrationService serverRegistrationService, + IHostingEnvironment hostingEnvironment, + ILogger logger, + IOptionsMonitor globalSettings, + IServerRoleAccessor serverRoleAccessor) + { + _serverRegistrationService = serverRegistrationService ?? + throw new ArgumentNullException(nameof(serverRegistrationService)); + _hostingEnvironment = hostingEnvironment; + _logger = logger; + _globalSettings = globalSettings.CurrentValue; + _serverRoleAccessor = serverRoleAccessor; + + Period = _globalSettings.DatabaseServerRegistrar.WaitTimeBetweenCalls; + globalSettings.OnChange(x => + { + _globalSettings = x; + Period = x.DatabaseServerRegistrar.WaitTimeBetweenCalls; + + _periodChanged?.Invoke(this, EventArgs.Empty); + }); + } + + public Task RunJobAsync() + { + + // If the IServerRoleAccessor has been changed away from ElectedServerRoleAccessor this task no longer makes sense, + // since all it's used for is to allow the ElectedServerRoleAccessor + // to figure out what role a given server has, so we just stop this task. + if (_serverRoleAccessor is not ElectedServerRoleAccessor) + { + return Task.CompletedTask; + } + + var serverAddress = _hostingEnvironment.ApplicationMainUrl?.ToString(); + if (serverAddress.IsNullOrWhiteSpace()) + { + _logger.LogWarning("No umbracoApplicationUrl for service (yet), skip."); + return Task.CompletedTask; + } + + try + { + _serverRegistrationService.TouchServer( + serverAddress!, + _globalSettings.DatabaseServerRegistrar.StaleServerTimeout); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update server record in database."); + } + + return Task.CompletedTask; + } +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJob.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJob.cs new file mode 100644 index 0000000000..ac88a8edce --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJob.cs @@ -0,0 +1,99 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; + +/// +/// Used to cleanup temporary file locations. +/// +/// +/// Will run on all servers - even though file upload should only be handled on the scheduling publisher, this will +/// ensure that in the case it happens on subscribers that they are cleaned up too. +/// +public class TempFileCleanupJob : IRecurringBackgroundJob +{ + public TimeSpan Period { get => TimeSpan.FromMinutes(60); } + + // Runs on all servers + public ServerRole[] ServerRoles { get => Enum.GetValues(); } + + // No-op event as the period never changes on this job + public event EventHandler PeriodChanged { add { } remove { } } + + private readonly TimeSpan _age = TimeSpan.FromDays(1); + private readonly IIOHelper _ioHelper; + private readonly ILogger _logger; + private readonly DirectoryInfo[] _tempFolders; + + /// + /// Initializes a new instance of the class. + /// + /// Helper service for IO operations. + /// The typed logger. + public TempFileCleanupJob(IIOHelper ioHelper, ILogger logger) + { + _ioHelper = ioHelper; + _logger = logger; + + _tempFolders = _ioHelper.GetTempFolders(); + } + + public Task RunJobAsync() + { + foreach (DirectoryInfo folder in _tempFolders) + { + CleanupFolder(folder); + } + + return Task.CompletedTask; + } + + private void CleanupFolder(DirectoryInfo folder) + { + CleanFolderResult result = _ioHelper.CleanFolder(folder, _age); + switch (result.Status) + { + case CleanFolderResultStatus.FailedAsDoesNotExist: + _logger.LogDebug("The cleanup folder doesn't exist {Folder}", folder.FullName); + break; + case CleanFolderResultStatus.FailedWithException: + foreach (CleanFolderResult.Error error in result.Errors!) + { + _logger.LogError(error.Exception, "Could not delete temp file {FileName}", + error.ErroringFile.FullName); + } + + break; + } + + folder.Refresh(); // In case it's changed during runtime + if (!folder.Exists) + { + _logger.LogDebug("The cleanup folder doesn't exist {Folder}", folder.FullName); + return; + } + + FileInfo[] files = folder.GetFiles("*.*", SearchOption.AllDirectories); + foreach (FileInfo file in files) + { + if (DateTime.UtcNow - file.LastWriteTimeUtc > _age) + { + try + { + file.IsReadOnly = false; + file.Delete(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not delete temp file {FileName}", file.FullName); + } + } + } + } + +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/WebhookFiring.cs b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/WebhookFiring.cs new file mode 100644 index 0000000000..fe8cf1e204 --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/Jobs/WebhookFiring.cs @@ -0,0 +1,124 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; + +public class WebhookFiring : IRecurringBackgroundJob +{ + private readonly ILogger _logger; + private readonly IWebhookRequestService _webhookRequestService; + private readonly IJsonSerializer _jsonSerializer; + private readonly IWebhookLogFactory _webhookLogFactory; + private readonly IWebhookLogService _webhookLogService; + private readonly IWebhookService _webHookService; + private readonly ICoreScopeProvider _coreScopeProvider; + private WebhookSettings _webhookSettings; + + public TimeSpan Period => _webhookSettings.Period; + + public TimeSpan Delay { get; } = TimeSpan.FromSeconds(20); + + // No-op event as the period never changes on this job + public event EventHandler PeriodChanged { add { } remove { } } + + public WebhookFiring( + ILogger logger, + IWebhookRequestService webhookRequestService, + IJsonSerializer jsonSerializer, + IWebhookLogFactory webhookLogFactory, + IWebhookLogService webhookLogService, + IWebhookService webHookService, + IOptionsMonitor webhookSettings, + ICoreScopeProvider coreScopeProvider) + { + _logger = logger; + _webhookRequestService = webhookRequestService; + _jsonSerializer = jsonSerializer; + _webhookLogFactory = webhookLogFactory; + _webhookLogService = webhookLogService; + _webHookService = webHookService; + _coreScopeProvider = coreScopeProvider; + _webhookSettings = webhookSettings.CurrentValue; + webhookSettings.OnChange(x => _webhookSettings = x); + } + + public async Task RunJobAsync() + { + IEnumerable requests; + using (ICoreScope scope = _coreScopeProvider.CreateCoreScope()) + { + scope.ReadLock(Constants.Locks.WebhookRequest); + requests = await _webhookRequestService.GetAllAsync(); + scope.Complete(); + } + + await Task.WhenAll(requests.Select(request => + { + using (ExecutionContext.SuppressFlow()) + { + return Task.Run(async () => + { + Webhook? webhook = await _webHookService.GetAsync(request.WebhookKey); + if (webhook is null) + { + return; + } + + HttpResponseMessage? response = await SendRequestAsync(webhook, request.EventAlias, request.RequestObject, request.RetryCount, CancellationToken.None); + + if ((response?.IsSuccessStatusCode ?? false) || request.RetryCount >= _webhookSettings.MaximumRetries) + { + await _webhookRequestService.DeleteAsync(request); + } + else + { + request.RetryCount++; + await _webhookRequestService.UpdateAsync(request); + } + }); + } + })); + } + + private async Task SendRequestAsync(Webhook webhook, string eventName, object? payload, int retryCount, CancellationToken cancellationToken) + { + using var httpClient = new HttpClient(); + + var serializedObject = _jsonSerializer.Serialize(payload); + var stringContent = new StringContent(serializedObject, Encoding.UTF8, "application/json"); + stringContent.Headers.TryAddWithoutValidation("Umb-Webhook-Event", eventName); + + foreach (KeyValuePair header in webhook.Headers) + { + stringContent.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + HttpResponseMessage? response = null; + try + { + response = await httpClient.PostAsync(webhook.Url, stringContent, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while sending webhook request for webhook {WebhookKey}.", webhook); + } + + var webhookResponseModel = new WebhookResponseModel + { + HttpResponseMessage = response, + RetryCount = retryCount, + }; + + WebhookLog log = await _webhookLogFactory.CreateAsync(eventName, webhookResponseModel, webhook, cancellationToken); + await _webhookLogService.CreateAsync(log); + + return response; + } +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedService.cs b/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedService.cs new file mode 100644 index 0000000000..80afdb903c --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedService.cs @@ -0,0 +1,127 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Serilog.Core; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.HostedServices; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs; + +public static class RecurringBackgroundJobHostedService +{ + public static Func CreateHostedServiceFactory(IServiceProvider serviceProvider) => + (IRecurringBackgroundJob job) => + { + Type hostedServiceType = typeof(RecurringBackgroundJobHostedService<>).MakeGenericType(job.GetType()); + return (IHostedService)ActivatorUtilities.CreateInstance(serviceProvider, hostedServiceType, job); + }; +} + +/// +/// Runs a recurring background job inside a hosted service. +/// Generic version for DependencyInjection +/// +/// Type of the Job +public class RecurringBackgroundJobHostedService : RecurringHostedServiceBase where TJob : IRecurringBackgroundJob +{ + + private readonly ILogger> _logger; + private readonly IMainDom _mainDom; + private readonly IRuntimeState _runtimeState; + private readonly IServerRoleAccessor _serverRoleAccessor; + private readonly IEventAggregator _eventAggregator; + private readonly IRecurringBackgroundJob _job; + + public RecurringBackgroundJobHostedService( + IRuntimeState runtimeState, + ILogger> logger, + IMainDom mainDom, + IServerRoleAccessor serverRoleAccessor, + IEventAggregator eventAggregator, + TJob job) + : base(logger, job.Period, job.Delay) + { + _runtimeState = runtimeState; + _logger = logger; + _mainDom = mainDom; + _serverRoleAccessor = serverRoleAccessor; + _eventAggregator = eventAggregator; + _job = job; + + _job.PeriodChanged += (sender, e) => ChangePeriod(_job.Period); + } + + /// + public override async Task PerformExecuteAsync(object? state) + { + var executingNotification = new Notifications.RecurringBackgroundJobExecutingNotification(_job, new EventMessages()); + await _eventAggregator.PublishAsync(executingNotification); + + try + { + + if (_runtimeState.Level != RuntimeLevel.Run) + { + _logger.LogDebug("Job not running as runlevel not yet ready"); + await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification)); + return; + } + + // Don't run on replicas nor unknown role servers + if (!_job.ServerRoles.Contains(_serverRoleAccessor.CurrentServerRole)) + { + _logger.LogDebug("Job not running on this server role"); + await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification)); + return; + } + + // Ensure we do not run if not main domain, but do NOT lock it + if (!_mainDom.IsMainDom) + { + _logger.LogDebug("Job not running as not MainDom"); + await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobIgnoredNotification(_job, new EventMessages()).WithStateFrom(executingNotification)); + return; + } + + + await _job.RunJobAsync(); + await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobExecutedNotification(_job, new EventMessages()).WithStateFrom(executingNotification)); + + + } + catch (Exception ex) + { + await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobFailedNotification(_job, new EventMessages()).WithStateFrom(executingNotification)); + _logger.LogError(ex, "Unhandled exception in recurring background job."); + } + + } + + public override async Task StartAsync(CancellationToken cancellationToken) + { + var startingNotification = new Notifications.RecurringBackgroundJobStartingNotification(_job, new EventMessages()); + await _eventAggregator.PublishAsync(startingNotification); + + await base.StartAsync(cancellationToken); + + await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobStartedNotification(_job, new EventMessages()).WithStateFrom(startingNotification)); + + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + var stoppingNotification = new Notifications.RecurringBackgroundJobStoppingNotification(_job, new EventMessages()); + await _eventAggregator.PublishAsync(stoppingNotification); + + await base.StopAsync(cancellationToken); + + await _eventAggregator.PublishAsync(new Notifications.RecurringBackgroundJobStoppedNotification(_job, new EventMessages()).WithStateFrom(stoppingNotification)); + } +} diff --git a/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceRunner.cs b/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceRunner.cs new file mode 100644 index 0000000000..0e56bfa2b1 --- /dev/null +++ b/src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceRunner.cs @@ -0,0 +1,81 @@ +using System.Linq; +using System.Threading; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.ModelsBuilder; + +namespace Umbraco.Cms.Infrastructure.BackgroundJobs; + +/// +/// A hosted service that discovers and starts hosted services for any recurring background jobs in the DI container. +/// +public class RecurringBackgroundJobHostedServiceRunner : IHostedService +{ + private readonly ILogger _logger; + private readonly List _jobs; + private readonly Func _jobFactory; + private IList _hostedServices = new List(); + + + public RecurringBackgroundJobHostedServiceRunner( + ILogger logger, + IEnumerable jobs, + Func jobFactory) + { + _jobs = jobs.ToList(); + _logger = logger; + _jobFactory = jobFactory; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Creating recurring background jobs hosted services"); + + // create hosted services for each background job + _hostedServices = _jobs.Select(_jobFactory).ToList(); + + _logger.LogInformation("Starting recurring background jobs hosted services"); + + foreach (IHostedService hostedService in _hostedServices) + { + try + { + _logger.LogInformation($"Starting background hosted service for {hostedService.GetType().Name}"); + await hostedService.StartAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) + { + _logger.LogError(exception, $"Failed to start background hosted service for {hostedService.GetType().Name}"); + } + } + + _logger.LogInformation("Completed starting recurring background jobs hosted services"); + + + } + + public async Task StopAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Stopping recurring background jobs hosted services"); + + foreach (IHostedService hostedService in _hostedServices) + { + try + { + _logger.LogInformation($"Stopping background hosted service for {hostedService.GetType().Name}"); + await hostedService.StopAsync(stoppingToken).ConfigureAwait(false); + } + catch (Exception exception) + { + _logger.LogError(exception, $"Failed to stop background hosted service for {hostedService.GetType().Name}"); + } + } + + _logger.LogInformation("Completed stopping recurring background jobs hosted services"); + + } +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index aadf24c79e..a7412acb06 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -76,6 +76,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); diff --git a/src/Umbraco.Infrastructure/Examine/BaseValueSetBuilder.cs b/src/Umbraco.Infrastructure/Examine/BaseValueSetBuilder.cs index 3574c3077f..83ecd85da4 100644 --- a/src/Umbraco.Infrastructure/Examine/BaseValueSetBuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/BaseValueSetBuilder.cs @@ -1,6 +1,9 @@ using Examine; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Examine; @@ -24,9 +27,26 @@ public abstract class BaseValueSetBuilder : IValueSetBuilder [Obsolete("Use the overload that specifies availableCultures, scheduled for removal in v14")] protected void AddPropertyValue(IProperty property, string? culture, string? segment, IDictionary>? values) - => AddPropertyValue(property, culture, segment, values, Enumerable.Empty()); + => AddPropertyValue( + property, + culture, + segment, + values, + Enumerable.Empty(), + StaticServiceProvider.Instance.GetRequiredService().GetAll().ToDictionary(x=>x.Key)); - protected void AddPropertyValue(IProperty property, string? culture, string? segment, IDictionary>? values, IEnumerable availableCultures) + [Obsolete("Use the overload that specifies availableCultures, scheduled for removal in v14")] + protected void AddPropertyValue(IProperty property, string? culture, string? segment, + IDictionary>? values, IEnumerable availableCultures) + => AddPropertyValue( + property, + culture, + segment, + values, + Enumerable.Empty(), + StaticServiceProvider.Instance.GetRequiredService().GetAll().ToDictionary(x=>x.Key)); + + protected void AddPropertyValue(IProperty property, string? culture, string? segment, IDictionary>? values, IEnumerable availableCultures, IDictionary contentTypeDictionary) { IDataEditor? editor = _propertyEditors[property.PropertyType.PropertyEditorAlias]; if (editor == null) @@ -35,7 +55,7 @@ public abstract class BaseValueSetBuilder : IValueSetBuilder } IEnumerable>> indexVals = - editor.PropertyIndexValueFactory.GetIndexValues(property, culture, segment, PublishedValuesOnly, availableCultures); + editor.PropertyIndexValueFactory.GetIndexValues(property, culture, segment, PublishedValuesOnly, availableCultures, contentTypeDictionary); foreach (KeyValuePair> keyVal in indexVals) { if (keyVal.Key.IsNullOrWhiteSpace()) diff --git a/src/Umbraco.Infrastructure/Examine/ContentValueSetBuilder.cs b/src/Umbraco.Infrastructure/Examine/ContentValueSetBuilder.cs index 228610879d..2d59e0ebe3 100644 --- a/src/Umbraco.Infrastructure/Examine/ContentValueSetBuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/ContentValueSetBuilder.cs @@ -21,13 +21,34 @@ public class ContentValueSetBuilder : BaseValueSetBuilder, IContentVal private static readonly object[] NoValue = new[] { "n" }; private static readonly object[] YesValue = new[] { "y" }; - private readonly IScopeProvider _scopeProvider; + private readonly ICoreScopeProvider _scopeProvider; private readonly IShortStringHelper _shortStringHelper; private readonly UrlSegmentProviderCollection _urlSegmentProviders; private readonly IUserService _userService; private readonly ILocalizationService _localizationService; + private readonly IContentTypeService _contentTypeService; + public ContentValueSetBuilder( + PropertyEditorCollection propertyEditors, + UrlSegmentProviderCollection urlSegmentProviders, + IUserService userService, + IShortStringHelper shortStringHelper, + ICoreScopeProvider scopeProvider, + bool publishedValuesOnly, + ILocalizationService localizationService, + IContentTypeService contentTypeService) + : base(propertyEditors, publishedValuesOnly) + { + _urlSegmentProviders = urlSegmentProviders; + _userService = userService; + _shortStringHelper = shortStringHelper; + _scopeProvider = scopeProvider; + _localizationService = localizationService; + _contentTypeService = contentTypeService; + } + + [Obsolete("Use non-obsolete ctor, scheduled for removal in v14")] public ContentValueSetBuilder( PropertyEditorCollection propertyEditors, UrlSegmentProviderCollection urlSegmentProviders, @@ -36,16 +57,20 @@ public class ContentValueSetBuilder : BaseValueSetBuilder, IContentVal IScopeProvider scopeProvider, bool publishedValuesOnly, ILocalizationService localizationService) - : base(propertyEditors, publishedValuesOnly) + : this( + propertyEditors, + urlSegmentProviders, + userService, + shortStringHelper, + scopeProvider, + publishedValuesOnly, + localizationService, + StaticServiceProvider.Instance.GetRequiredService()) { - _urlSegmentProviders = urlSegmentProviders; - _userService = userService; - _shortStringHelper = shortStringHelper; - _scopeProvider = scopeProvider; - _localizationService = localizationService; + } - [Obsolete("Use the constructor that takes an ILocalizationService, scheduled for removal in v14")] + [Obsolete("Use non-obsolete ctor, scheduled for removal in v14")] public ContentValueSetBuilder( PropertyEditorCollection propertyEditors, UrlSegmentProviderCollection urlSegmentProviders, @@ -60,7 +85,8 @@ public class ContentValueSetBuilder : BaseValueSetBuilder, IContentVal shortStringHelper, scopeProvider, publishedValuesOnly, - StaticServiceProvider.Instance.GetRequiredService()) + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) { } @@ -72,7 +98,7 @@ public class ContentValueSetBuilder : BaseValueSetBuilder, IContentVal // We can lookup all of the creator/writer names at once which can save some // processing below instead of one by one. - using (IScope scope = _scopeProvider.CreateScope()) + using (ICoreScope scope = _scopeProvider.CreateCoreScope()) { creatorIds = _userService.GetProfilesById(content.Select(x => x.CreatorId).ToArray()) .ToDictionary(x => x.Id, x => x); @@ -86,6 +112,8 @@ public class ContentValueSetBuilder : BaseValueSetBuilder, IContentVal private IEnumerable GetValueSetsEnumerable(IContent[] content, Dictionary creatorIds, Dictionary writerIds) { + IDictionary contentTypeDictionary = _contentTypeService.GetAll().ToDictionary(x => x.Key); + // TODO: There is a lot of boxing going on here and ultimately all values will be boxed by Lucene anyways // but I wonder if there's a way to reduce the boxing that we have to do or if it will matter in the end since // Lucene will do it no matter what? One idea was to create a `FieldValue` struct which would contain `object`, `object[]`, `ValueType` and `ValueType[]` @@ -162,13 +190,13 @@ public class ContentValueSetBuilder : BaseValueSetBuilder, IContentVal { if (!property.PropertyType.VariesByCulture()) { - AddPropertyValue(property, null, null, values, availableCultures); + AddPropertyValue(property, null, null, values, availableCultures, contentTypeDictionary); } else { foreach (var culture in c.AvailableCultures) { - AddPropertyValue(property, culture.ToLowerInvariant(), null, values, availableCultures); + AddPropertyValue(property, culture.ToLowerInvariant(), null, values, availableCultures, contentTypeDictionary); } } } diff --git a/src/Umbraco.Infrastructure/Examine/MediaValueSetBuilder.cs b/src/Umbraco.Infrastructure/Examine/MediaValueSetBuilder.cs index fa7d6509cd..d2da36b347 100644 --- a/src/Umbraco.Infrastructure/Examine/MediaValueSetBuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/MediaValueSetBuilder.cs @@ -1,10 +1,12 @@ using Examine; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Examine; @@ -14,6 +16,7 @@ public class MediaValueSetBuilder : BaseValueSetBuilder private readonly ContentSettings _contentSettings; private readonly MediaUrlGeneratorCollection _mediaUrlGenerators; private readonly IShortStringHelper _shortStringHelper; + private readonly IContentTypeService _contentTypeService; private readonly UrlSegmentProviderCollection _urlSegmentProviders; private readonly IUserService _userService; @@ -23,19 +26,41 @@ public class MediaValueSetBuilder : BaseValueSetBuilder MediaUrlGeneratorCollection mediaUrlGenerators, IUserService userService, IShortStringHelper shortStringHelper, - IOptions contentSettings) + IOptions contentSettings, + IContentTypeService contentTypeService) : base(propertyEditors, false) { _urlSegmentProviders = urlSegmentProviders; _mediaUrlGenerators = mediaUrlGenerators; _userService = userService; _shortStringHelper = shortStringHelper; + _contentTypeService = contentTypeService; _contentSettings = contentSettings.Value; } + [Obsolete("Use non-obsolete ctor, scheduled for removal in v14")] + public MediaValueSetBuilder( + PropertyEditorCollection propertyEditors, + UrlSegmentProviderCollection urlSegmentProviders, + MediaUrlGeneratorCollection mediaUrlGenerators, + IUserService userService, + IShortStringHelper shortStringHelper, + IOptions contentSettings) + : this(propertyEditors, + urlSegmentProviders, + mediaUrlGenerators, + userService, + shortStringHelper, + contentSettings, + StaticServiceProvider.Instance.GetRequiredService()) + { + + } /// public override IEnumerable GetValueSets(params IMedia[] media) { + IDictionary contentTypeDictionary = _contentTypeService.GetAll().ToDictionary(x => x.Key); + foreach (IMedia m in media) { var urlValue = m.GetUrlSegment(_shortStringHelper, _urlSegmentProviders); @@ -65,7 +90,7 @@ public class MediaValueSetBuilder : BaseValueSetBuilder foreach (IProperty property in m.Properties) { - AddPropertyValue(property, null, null, values, m.AvailableCultures); + AddPropertyValue(property, null, null, values, m.AvailableCultures, contentTypeDictionary); } var vs = new ValueSet(m.Id.ToInvariantString(), IndexTypes.Media, m.ContentType.Alias, values); diff --git a/src/Umbraco.Infrastructure/Examine/MemberValueSetBuilder.cs b/src/Umbraco.Infrastructure/Examine/MemberValueSetBuilder.cs index 1b0bf7219f..8fe2a56856 100644 --- a/src/Umbraco.Infrastructure/Examine/MemberValueSetBuilder.cs +++ b/src/Umbraco.Infrastructure/Examine/MemberValueSetBuilder.cs @@ -1,20 +1,34 @@ using Examine; +using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.Examine; public class MemberValueSetBuilder : BaseValueSetBuilder { - public MemberValueSetBuilder(PropertyEditorCollection propertyEditors) + private readonly IContentTypeService _contentTypeService; + + public MemberValueSetBuilder(PropertyEditorCollection propertyEditors, IContentTypeService contentTypeService) : base(propertyEditors, false) + { + _contentTypeService = contentTypeService; + } + + [Obsolete("Use non-obsolete ctor, scheduled for removal in v14")] + public MemberValueSetBuilder(PropertyEditorCollection propertyEditors) + : this(propertyEditors, StaticServiceProvider.Instance.GetRequiredService()) { } /// public override IEnumerable GetValueSets(params IMember[] members) { + IDictionary contentTypeDictionary = _contentTypeService.GetAll().ToDictionary(x => x.Key); + foreach (IMember m in members) { var values = new Dictionary> @@ -37,7 +51,7 @@ public class MemberValueSetBuilder : BaseValueSetBuilder foreach (IProperty property in m.Properties) { - AddPropertyValue(property, null, null, values, m.AvailableCultures); + AddPropertyValue(property, null, null, values, m.AvailableCultures, contentTypeDictionary); } var vs = new ValueSet(m.Id.ToInvariantString(), IndexTypes.Member, m.ContentType.Alias, values); diff --git a/src/Umbraco.Infrastructure/Extensions/ServiceCollectionExtensions.cs b/src/Umbraco.Infrastructure/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..efaac24ceb --- /dev/null +++ b/src/Umbraco.Infrastructure/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Infrastructure.BackgroundJobs; + +namespace Umbraco.Extensions; + +public static class ServiceCollectionExtensions +{ + /// + /// Adds a recurring background job with an implementation type of + /// to the specified . + /// + public static void AddRecurringBackgroundJob( + this IServiceCollection services) + where TJob : class, IRecurringBackgroundJob => + services.AddSingleton(); + + /// + /// Adds a recurring background job with an implementation type of + /// using the factory + /// to the specified . + /// + public static void AddRecurringBackgroundJob( + this IServiceCollection services, + Func implementationFactory) + where TJob : class, IRecurringBackgroundJob => + services.AddSingleton(implementationFactory); + +} + diff --git a/src/Umbraco.Infrastructure/HostedServices/ContentVersionCleanup.cs b/src/Umbraco.Infrastructure/HostedServices/ContentVersionCleanup.cs index 37eeb668f9..1ecfcbf926 100644 --- a/src/Umbraco.Infrastructure/HostedServices/ContentVersionCleanup.cs +++ b/src/Umbraco.Infrastructure/HostedServices/ContentVersionCleanup.cs @@ -11,6 +11,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices; /// /// Recurring hosted service that executes the content history cleanup. /// +[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.ContentVersionCleanupJob instead. This class will be removed in Umbraco 14.")] public class ContentVersionCleanup : RecurringHostedServiceBase { private readonly ILogger _logger; diff --git a/src/Umbraco.Infrastructure/HostedServices/HealthCheckNotifier.cs b/src/Umbraco.Infrastructure/HostedServices/HealthCheckNotifier.cs index e1a10d9f71..5ee76d1a18 100644 --- a/src/Umbraco.Infrastructure/HostedServices/HealthCheckNotifier.cs +++ b/src/Umbraco.Infrastructure/HostedServices/HealthCheckNotifier.cs @@ -20,6 +20,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices; /// /// Hosted service implementation for recurring health check notifications. /// +[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.HealthCheckNotifierJob instead. This class will be removed in Umbraco 14.")] public class HealthCheckNotifier : RecurringHostedServiceBase { private readonly HealthCheckCollection _healthChecks; diff --git a/src/Umbraco.Infrastructure/HostedServices/KeepAlive.cs b/src/Umbraco.Infrastructure/HostedServices/KeepAlive.cs index 5db59ff225..978ffa2dd1 100644 --- a/src/Umbraco.Infrastructure/HostedServices/KeepAlive.cs +++ b/src/Umbraco.Infrastructure/HostedServices/KeepAlive.cs @@ -17,6 +17,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices; /// /// Hosted service implementation for keep alive feature. /// +[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.KeepAliveJob instead. This class will be removed in Umbraco 14.")] public class KeepAlive : RecurringHostedServiceBase { private readonly IHostingEnvironment _hostingEnvironment; diff --git a/src/Umbraco.Infrastructure/HostedServices/LogScrubber.cs b/src/Umbraco.Infrastructure/HostedServices/LogScrubber.cs index 9ae0dfe656..4c3df658c6 100644 --- a/src/Umbraco.Infrastructure/HostedServices/LogScrubber.cs +++ b/src/Umbraco.Infrastructure/HostedServices/LogScrubber.cs @@ -18,6 +18,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices; /// /// Will only run on non-replica servers. /// +[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.LogScrubberJob instead. This class will be removed in Umbraco 14.")] public class LogScrubber : RecurringHostedServiceBase { private readonly IAuditService _auditService; diff --git a/src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs b/src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs index c100da0ab2..a35f7aa956 100644 --- a/src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs +++ b/src/Umbraco.Infrastructure/HostedServices/RecurringHostedServiceBase.cs @@ -107,7 +107,7 @@ public abstract class RecurringHostedServiceBase : IHostedService, IDisposable } /// - public Task StartAsync(CancellationToken cancellationToken) + public virtual Task StartAsync(CancellationToken cancellationToken) { using (!ExecutionContext.IsFlowSuppressed() ? (IDisposable)ExecutionContext.SuppressFlow() : null) { @@ -118,7 +118,7 @@ public abstract class RecurringHostedServiceBase : IHostedService, IDisposable } /// - public Task StopAsync(CancellationToken cancellationToken) + public virtual Task StopAsync(CancellationToken cancellationToken) { _period = Timeout.InfiniteTimeSpan; _timer?.Change(Timeout.Infinite, 0); diff --git a/src/Umbraco.Infrastructure/HostedServices/ReportSiteTask.cs b/src/Umbraco.Infrastructure/HostedServices/ReportSiteTask.cs index 52a877a976..a0eb8cfb89 100644 --- a/src/Umbraco.Infrastructure/HostedServices/ReportSiteTask.cs +++ b/src/Umbraco.Infrastructure/HostedServices/ReportSiteTask.cs @@ -13,6 +13,7 @@ using Umbraco.Cms.Core.Telemetry.Models; namespace Umbraco.Cms.Infrastructure.HostedServices; +[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.ReportSiteJob instead. This class will be removed in Umbraco 14.")] public class ReportSiteTask : RecurringHostedServiceBase { private static HttpClient _httpClient = new(); diff --git a/src/Umbraco.Infrastructure/HostedServices/ScheduledPublishing.cs b/src/Umbraco.Infrastructure/HostedServices/ScheduledPublishing.cs index da1fbaf157..efbd8017df 100644 --- a/src/Umbraco.Infrastructure/HostedServices/ScheduledPublishing.cs +++ b/src/Umbraco.Infrastructure/HostedServices/ScheduledPublishing.cs @@ -17,6 +17,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices; /// /// Runs only on non-replica servers. /// +[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.ScheduledPublishingJob instead. This class will be removed in Umbraco 14.")] public class ScheduledPublishing : RecurringHostedServiceBase { private readonly IContentService _contentService; diff --git a/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTask.cs b/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTask.cs index e4e5700496..fbbdab8878 100644 --- a/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTask.cs +++ b/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTask.cs @@ -13,6 +13,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices.ServerRegistration; /// /// Implements periodic database instruction processing as a hosted service. /// +[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.ServerRegistration.InstructionProcessJob instead. This class will be removed in Umbraco 14.")] public class InstructionProcessTask : RecurringHostedServiceBase { private readonly ILogger _logger; diff --git a/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTask.cs b/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTask.cs index 730282c6b0..a844c33ad6 100644 --- a/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTask.cs +++ b/src/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTask.cs @@ -15,6 +15,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices.ServerRegistration; /// /// Implements periodic server "touching" (to mark as active/deactive) as a hosted service. /// +[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.ServerRegistration.TouchServerJob instead. This class will be removed in Umbraco 14.")] public class TouchServerTask : RecurringHostedServiceBase { private readonly IHostingEnvironment _hostingEnvironment; diff --git a/src/Umbraco.Infrastructure/HostedServices/TempFileCleanup.cs b/src/Umbraco.Infrastructure/HostedServices/TempFileCleanup.cs index cf46e38750..81de651e79 100644 --- a/src/Umbraco.Infrastructure/HostedServices/TempFileCleanup.cs +++ b/src/Umbraco.Infrastructure/HostedServices/TempFileCleanup.cs @@ -14,6 +14,7 @@ namespace Umbraco.Cms.Infrastructure.HostedServices; /// Will run on all servers - even though file upload should only be handled on the scheduling publisher, this will /// ensure that in the case it happens on subscribers that they are cleaned up too. /// +[Obsolete("Use Umbraco.Cms.Infrastructure.BackgroundJobs.TempFileCleanupJob instead. This class will be removed in Umbraco 14.")] public class TempFileCleanup : RecurringHostedServiceBase { private readonly TimeSpan _age = TimeSpan.FromDays(1); diff --git a/src/Umbraco.Infrastructure/Logging/Serilog/Enrichers/ThreadAbortExceptionEnricher.cs b/src/Umbraco.Infrastructure/Logging/Serilog/Enrichers/ThreadAbortExceptionEnricher.cs index 4083aa7311..650b592779 100644 --- a/src/Umbraco.Infrastructure/Logging/Serilog/Enrichers/ThreadAbortExceptionEnricher.cs +++ b/src/Umbraco.Infrastructure/Logging/Serilog/Enrichers/ThreadAbortExceptionEnricher.cs @@ -67,7 +67,7 @@ public class ThreadAbortExceptionEnricher : ILogEventEnricher private void DumpThreadAborts(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) { - if (!IsTimeoutThreadAbortException(logEvent.Exception)) + if (logEvent.Exception is null || !IsTimeoutThreadAbortException(logEvent.Exception)) { return; } diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs index c52e6b8f25..f1263d19fe 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs @@ -993,31 +993,19 @@ internal class DatabaseDataCreator private void CreateLockData() { // all lock objects - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.Servers, Name = "Servers" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.ContentTypes, Name = "ContentTypes" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.ContentTree, Name = "ContentTree" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.MediaTypes, Name = "MediaTypes" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.MediaTree, Name = "MediaTree" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.MemberTypes, Name = "MemberTypes" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.MemberTree, Name = "MemberTree" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.Domains, Name = "Domains" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.KeyValues, Name = "KeyValues" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.Languages, Name = "Languages" }); - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.ScheduledPublishing, Name = "ScheduledPublishing" }); - - _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, - new LockDto { Id = Constants.Locks.MainDom, Name = "MainDom" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.Servers, Name = "Servers" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.ContentTypes, Name = "ContentTypes" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.ContentTree, Name = "ContentTree" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MediaTypes, Name = "MediaTypes" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MediaTree, Name = "MediaTree" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MemberTypes, Name = "MemberTypes" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MemberTree, Name = "MemberTree" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.Domains, Name = "Domains" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.KeyValues, Name = "KeyValues" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.Languages, Name = "Languages" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.ScheduledPublishing, Name = "ScheduledPublishing" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.MainDom, Name = "MainDom" }); + _database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.WebhookRequest, Name = "WebhookRequest" }); } private void CreateContentTypeData() diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs index c13389f52b..941c73010c 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -89,6 +89,7 @@ public class DatabaseSchemaCreator typeof(Webhook2EventsDto), typeof(Webhook2HeadersDto), typeof(WebhookLogDto), + typeof(WebhookRequestDto), }; private readonly IUmbracoDatabase _database; diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 11c2736228..77eed2a38b 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -98,6 +98,9 @@ public class UmbracoPlan : MigrationPlan // To 13.0.0 To("{C76D9C9A-635B-4D2C-A301-05642A523E9D}"); + To("{D5139400-E507-4259-A542-C67358F7E329}"); + To("{4E652F18-9A29-4656-A899-E3F39069C47E}"); + To("{148714C8-FE0D-4553-B034-439D91468761}"); // To 14.0.0 To("{419827A0-4FCE-464B-A8F3-247C6092AF55}"); diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddWebhookRequest.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddWebhookRequest.cs new file mode 100644 index 0000000000..458cd26b31 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddWebhookRequest.cs @@ -0,0 +1,35 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_13_0_0; + +public class AddWebhookRequest : MigrationBase +{ + public AddWebhookRequest(IMigrationContext context) : base(context) + { + } + + protected override void Migrate() + { + IEnumerable tables = SqlSyntax.GetTablesInSchema(Context.Database).ToArray(); + if (tables.InvariantContains(Constants.DatabaseSchema.Tables.WebhookRequest) is false) + { + Create.Table().Do(); + } + + Sql sql = Database.SqlContext.Sql() + .Select() + .From() + .Where(x => x.Id == Constants.Locks.WebhookRequest); + + LockDto? webhookRequestLock = Database.FirstOrDefault(sql); + + if (webhookRequestLock is null) + { + Database.Insert(Constants.DatabaseSchema.Tables.Lock, "id", false, new LockDto { Id = Constants.Locks.WebhookRequest, Name = "WebhookRequest" }); + } + } +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/RenameEventNameColumn.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/RenameEventNameColumn.cs new file mode 100644 index 0000000000..7a69a2441f --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/RenameEventNameColumn.cs @@ -0,0 +1,28 @@ +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_13_0_0; + +public class RenameEventNameColumn : MigrationBase +{ + public RenameEventNameColumn(IMigrationContext context) : base(context) + { + } + + protected override void Migrate() + { + // This check is here because we renamed a column from 13-rc1 to 13-rc2, the previous migration adds the table + // so if you are upgrading from 13-rc1 to 13-rc2 then this column will not exist. + // If you are however upgrading from 12, then this column will exist, and thus there is no need to rename it. + if (ColumnExists(Constants.DatabaseSchema.Tables.WebhookLog, "eventName") is false) + { + return; + } + + Rename + .Column("eventName") + .OnTable(Constants.DatabaseSchema.Tables.WebhookLog) + .To("eventAlias") + .Do(); + } + +} diff --git a/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/RenameWebhookIdToKey.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/RenameWebhookIdToKey.cs new file mode 100644 index 0000000000..e68aa258a9 --- /dev/null +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/RenameWebhookIdToKey.cs @@ -0,0 +1,27 @@ +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Infrastructure.Migrations.Upgrade.V_13_0_0; + +public class RenameWebhookIdToKey : MigrationBase +{ + public RenameWebhookIdToKey(IMigrationContext context) : base(context) + { + } + + protected override void Migrate() + { + // This check is here because we renamed a column from 13-rc1 to 13-rc2, the previous migration adds the table + // so if you are upgrading from 13-rc1 to 13-rc2 then this column will not exist. + // If you are however upgrading from 12, then this column will exist, and thus there is no need to rename it. + if (ColumnExists(Constants.DatabaseSchema.Tables.WebhookLog, "webhookId") is false) + { + return; + } + + Rename + .Column("webhookId") + .OnTable(Constants.DatabaseSchema.Tables.WebhookLog) + .To("webhookKey") + .Do(); + } +} diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobExecutedNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobExecutedNotification.cs new file mode 100644 index 0000000000..8d2fbf96aa --- /dev/null +++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobExecutedNotification.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Infrastructure.BackgroundJobs; + +namespace Umbraco.Cms.Infrastructure.Notifications +{ + public sealed class RecurringBackgroundJobExecutedNotification : RecurringBackgroundJobNotification + { + public RecurringBackgroundJobExecutedNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages) + { + } + } +} diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobExecutingNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobExecutingNotification.cs new file mode 100644 index 0000000000..71f5cf3edc --- /dev/null +++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobExecutingNotification.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Infrastructure.BackgroundJobs; + +namespace Umbraco.Cms.Infrastructure.Notifications +{ + public sealed class RecurringBackgroundJobExecutingNotification : RecurringBackgroundJobNotification + { + public RecurringBackgroundJobExecutingNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages) + { + } + } +} diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobFailedNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobFailedNotification.cs new file mode 100644 index 0000000000..594f01fc7b --- /dev/null +++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobFailedNotification.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Infrastructure.BackgroundJobs; + +namespace Umbraco.Cms.Infrastructure.Notifications +{ + public sealed class RecurringBackgroundJobFailedNotification : RecurringBackgroundJobNotification + { + public RecurringBackgroundJobFailedNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages) + { + } + } +} diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobIgnoredNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobIgnoredNotification.cs new file mode 100644 index 0000000000..8c3a0079d7 --- /dev/null +++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobIgnoredNotification.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Infrastructure.BackgroundJobs; + +namespace Umbraco.Cms.Infrastructure.Notifications +{ + public sealed class RecurringBackgroundJobIgnoredNotification : RecurringBackgroundJobNotification + { + public RecurringBackgroundJobIgnoredNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages) + { + } + } +} diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobNotification.cs new file mode 100644 index 0000000000..f9185cb412 --- /dev/null +++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobNotification.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Infrastructure.BackgroundJobs; + +namespace Umbraco.Cms.Infrastructure.Notifications +{ + public class RecurringBackgroundJobNotification : ObjectNotification + { + public IRecurringBackgroundJob Job { get; } + public RecurringBackgroundJobNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages) => Job = target; + } +} diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStartedNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStartedNotification.cs new file mode 100644 index 0000000000..dca1e69d40 --- /dev/null +++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStartedNotification.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Infrastructure.BackgroundJobs; + +namespace Umbraco.Cms.Infrastructure.Notifications +{ + public sealed class RecurringBackgroundJobStartedNotification : RecurringBackgroundJobNotification + { + public RecurringBackgroundJobStartedNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages) + { + } + } +} diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStartingNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStartingNotification.cs new file mode 100644 index 0000000000..3ee8d2a710 --- /dev/null +++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStartingNotification.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Infrastructure.BackgroundJobs; + +namespace Umbraco.Cms.Infrastructure.Notifications +{ + public sealed class RecurringBackgroundJobStartingNotification : RecurringBackgroundJobNotification + { + public RecurringBackgroundJobStartingNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages) + { + } + } +} diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStoppedNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStoppedNotification.cs new file mode 100644 index 0000000000..a1df71a4ee --- /dev/null +++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStoppedNotification.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Infrastructure.BackgroundJobs; + +namespace Umbraco.Cms.Infrastructure.Notifications +{ + public sealed class RecurringBackgroundJobStoppedNotification : RecurringBackgroundJobNotification + { + public RecurringBackgroundJobStoppedNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages) + { + } + } +} diff --git a/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStoppingNotification.cs b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStoppingNotification.cs new file mode 100644 index 0000000000..985a20e286 --- /dev/null +++ b/src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStoppingNotification.cs @@ -0,0 +1,12 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Infrastructure.BackgroundJobs; + +namespace Umbraco.Cms.Infrastructure.Notifications +{ + public sealed class RecurringBackgroundJobStoppingNotification : RecurringBackgroundJobNotification + { + public RecurringBackgroundJobStoppingNotification(IRecurringBackgroundJob target, EventMessages messages) : base(target, messages) + { + } + } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs index a8606c7391..f98226248e 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs @@ -13,7 +13,7 @@ internal class WebhookLogDto [PrimaryKeyColumn(AutoIncrement = true)] public int Id { get; set; } - [Column("webhookId")] + [Column("webhookKey")] public Guid WebhookKey { get; set; } [Column(Name = "key")] @@ -33,27 +33,31 @@ internal class WebhookLogDto [NullSetting(NullSetting = NullSettings.NotNull)] public string Url { get; set; } = string.Empty; - [Column(Name = "eventName")] + [Column(Name = "eventAlias")] [NullSetting(NullSetting = NullSettings.NotNull)] - public string EventName { get; set; } = string.Empty; + public string EventAlias { get; set; } = string.Empty; [Column(Name = "retryCount")] [NullSetting(NullSetting = NullSettings.NotNull)] public int RetryCount { get; set; } [Column(Name = "requestHeaders")] + [SpecialDbType(SpecialDbTypes.NVARCHARMAX)] [NullSetting(NullSetting = NullSettings.NotNull)] public string RequestHeaders { get; set; } = string.Empty; [Column(Name = "requestBody")] + [SpecialDbType(SpecialDbTypes.NVARCHARMAX)] [NullSetting(NullSetting = NullSettings.NotNull)] public string RequestBody { get; set; } = string.Empty; [Column(Name = "responseHeaders")] + [SpecialDbType(SpecialDbTypes.NVARCHARMAX)] [NullSetting(NullSetting = NullSettings.NotNull)] public string ResponseHeaders { get; set; } = string.Empty; [Column(Name = "responseBody")] + [SpecialDbType(SpecialDbTypes.NVARCHARMAX)] [NullSetting(NullSetting = NullSettings.NotNull)] public string ResponseBody { get; set; } = string.Empty; } diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookRequestDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookRequestDto.cs new file mode 100644 index 0000000000..e1f55ad042 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookRequestDto.cs @@ -0,0 +1,28 @@ +using NPoco; +using Umbraco.Cms.Core; +using Umbraco.Cms.Infrastructure.Persistence.DatabaseAnnotations; + +namespace Umbraco.Cms.Infrastructure.Persistence.Dtos; + +[TableName(Constants.DatabaseSchema.Tables.WebhookRequest)] +[PrimaryKey("id")] +[ExplicitColumns] +public class WebhookRequestDto +{ + [Column("id")] + [PrimaryKeyColumn(AutoIncrement = true)] + public int Id { get; set; } + + [Column("webhookKey")] + public Guid WebhookKey { get; set; } + + [Column("eventName")] + public string Alias { get; set; } = string.Empty; + + [Column(Name = "requestObject")] + [NullSetting(NullSetting = NullSettings.Null)] + public string? RequestObject { get; set; } + + [Column("retryCount")] + public int RetryCount { get; set; } +} diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/WebhookLogFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/WebhookLogFactory.cs index 2cc6d5d55b..ff1378ed2d 100644 --- a/src/Umbraco.Infrastructure/Persistence/Factories/WebhookLogFactory.cs +++ b/src/Umbraco.Infrastructure/Persistence/Factories/WebhookLogFactory.cs @@ -10,7 +10,7 @@ internal static class WebhookLogFactory new() { Date = log.Date, - EventName = log.EventName, + EventAlias = log.EventAlias, RequestBody = log.RequestBody ?? string.Empty, ResponseBody = log.ResponseBody, RetryCount = log.RetryCount, @@ -27,7 +27,7 @@ internal static class WebhookLogFactory new() { Date = dto.Date, - EventName = dto.EventName, + EventAlias = dto.EventAlias, RequestBody = dto.RequestBody, ResponseBody = dto.ResponseBody, RetryCount = dto.RetryCount, diff --git a/src/Umbraco.Infrastructure/Persistence/Factories/WebhookRequestFactory.cs b/src/Umbraco.Infrastructure/Persistence/Factories/WebhookRequestFactory.cs new file mode 100644 index 0000000000..dd0648588b --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Factories/WebhookRequestFactory.cs @@ -0,0 +1,27 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; + +namespace Umbraco.Cms.Infrastructure.Persistence.Factories; + +internal static class WebhookRequestFactory +{ + public static WebhookRequestDto CreateDto(WebhookRequest webhookRequest) => + new() + { + Alias = webhookRequest.EventAlias, + Id = webhookRequest.Id, + WebhookKey = webhookRequest.WebhookKey, + RequestObject = webhookRequest.RequestObject, + RetryCount = webhookRequest.RetryCount, + }; + + public static WebhookRequest CreateModel(WebhookRequestDto webhookRequestDto) => + new() + { + EventAlias = webhookRequestDto.Alias, + Id = webhookRequestDto.Id, + WebhookKey = webhookRequestDto.WebhookKey, + RequestObject = webhookRequestDto.RequestObject, + RetryCount = webhookRequestDto.RetryCount, + }; +} diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs index 5ff8fe11c7..3ae4248119 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs @@ -1084,50 +1084,58 @@ namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement { // Get all references from our core built in DataEditors/Property Editors // Along with seeing if developers want to collect additional references from the DataValueReferenceFactories collection - var trackedRelations = _dataValueReferenceFactories.GetAllReferences(entity.Properties, PropertyEditors); + ISet references = _dataValueReferenceFactories.GetAllReferences(entity.Properties, PropertyEditors); // First delete all auto-relations for this entity - var relationTypeAliases = _dataValueReferenceFactories.GetAutomaticRelationTypesAliases(entity.Properties, PropertyEditors).ToArray(); - RelationRepository.DeleteByParent(entity.Id, relationTypeAliases); + ISet automaticRelationTypeAliases = _dataValueReferenceFactories.GetAutomaticRelationTypesAliases(entity.Properties, PropertyEditors); + RelationRepository.DeleteByParent(entity.Id, automaticRelationTypeAliases.ToArray()); - if (trackedRelations.Count == 0) + if (references.Count == 0) { + // No need to add new references/relations return; } - var udiToGuids = trackedRelations.Select(x => x.Udi as GuidUdi).WhereNotNull().ToDictionary(x => (Udi)x, x => x.Guid); + // Lookup node IDs for all GUID based UDIs + IEnumerable keys = references.Select(x => x.Udi).OfType().Select(x => x.Guid); + var keysLookup = Database.FetchByGroups(keys, Constants.Sql.MaxParameterCount, guids => + { + return Sql() + .Select(x => x.NodeId, x => x.UniqueId) + .From() + .WhereIn(x => x.UniqueId, guids); + }).ToDictionary(x => x.UniqueId, x => x.NodeId); - // lookup in the DB all INT ids for the GUIDs and chuck into a dictionary - var keyToIds = Database.FetchByGroups(udiToGuids.Values, Constants.Sql.MaxParameterCount, guids => Sql() - .Select(x => x.NodeId, x => x.UniqueId) - .From() - .WhereIn(x => x.UniqueId, guids)) - .ToDictionary(x => x.UniqueId, x => x.NodeId); + // Lookup all relation type IDs + var relationTypeLookup = RelationTypeRepository.GetMany(Array.Empty()).ToDictionary(x => x.Alias, x => x.Id); - var allRelationTypes = RelationTypeRepository.GetMany(Array.Empty()).ToDictionary(x => x.Alias, x => x); - - IEnumerable toSave = trackedRelations.Select(rel => + // Get all valid relations + var relations = new List(references.Count); + foreach (UmbracoEntityReference reference in references) + { + if (!automaticRelationTypeAliases.Contains(reference.RelationTypeAlias)) { - if (!allRelationTypes.TryGetValue(rel.RelationTypeAlias, out IRelationType? relationType)) - { - throw new InvalidOperationException($"The relation type {rel.RelationTypeAlias} does not exist"); - } - - if (!udiToGuids.TryGetValue(rel.Udi, out Guid guid)) - { - return null; // This shouldn't happen! - } - - if (!keyToIds.TryGetValue(guid, out var id)) - { - return null; // This shouldn't happen! - } - - return new ReadOnlyRelation(entity.Id, id, relationType.Id); - }).WhereNotNull(); + // Returning a reference that doesn't use an automatic relation type is an issue that should be fixed in code + Logger.LogError("The reference to {Udi} uses a relation type {RelationTypeAlias} that is not an automatic relation type.", reference.Udi, reference.RelationTypeAlias); + } + else if (!relationTypeLookup.TryGetValue(reference.RelationTypeAlias, out int relationTypeId)) + { + // A non-existent relation type could be caused by an environment issue (e.g. it was manually removed) + Logger.LogWarning("The reference to {Udi} uses a relation type {RelationTypeAlias} that does not exist.", reference.Udi, reference.RelationTypeAlias); + } + else if (reference.Udi is not GuidUdi udi || !keysLookup.TryGetValue(udi.Guid, out var id)) + { + // Relations only support references to items that are stored in the NodeDto table (because of foreign key constraints) + Logger.LogInformation("The reference to {Udi} can not be saved as relation, because doesn't have a node ID.", reference.Udi); + } + else + { + relations.Add(new ReadOnlyRelation(entity.Id, id, relationTypeId)); + } + } // Save bulk relations - RelationRepository.SaveBulk(toSave); + RelationRepository.SaveBulk(relations); } /// diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookRepository.cs index af6d651e44..b8fe5e52fe 100644 --- a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookRepository.cs +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookRepository.cs @@ -57,14 +57,14 @@ public class WebhookRepository : IWebhookRepository return webhookDto is null ? null : await DtoToEntity(webhookDto); } - public async Task> GetByEventNameAsync(string eventName) + public async Task> GetByAliasAsync(string alias) { Sql? sql = _scopeAccessor.AmbientScope?.Database.SqlContext.Sql() .SelectAll() .From() .InnerJoin() .On(left => left.Id, right => right.WebhookId) - .Where(x => x.Event == eventName); + .Where(x => x.Event == alias); List? webhookDtos = await _scopeAccessor.AmbientScope?.Database.FetchAsync(sql)!; diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookRequestRepository.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookRequestRepository.cs new file mode 100644 index 0000000000..21b25c77a7 --- /dev/null +++ b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookRequestRepository.cs @@ -0,0 +1,65 @@ +using NPoco; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Persistence.Repositories; +using Umbraco.Cms.Infrastructure.Persistence.Dtos; +using Umbraco.Cms.Infrastructure.Persistence.Factories; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement; + +public class WebhookRequestRepository : IWebhookRequestRepository +{ + private readonly IScopeAccessor _scopeAccessor; + + public WebhookRequestRepository(IScopeAccessor scopeAccessor) => _scopeAccessor = scopeAccessor; + + private IUmbracoDatabase Database + { + get + { + if (_scopeAccessor.AmbientScope is null) + { + throw new NotSupportedException("Need to be executed in a scope"); + } + + return _scopeAccessor.AmbientScope.Database; + } + } + + public async Task CreateAsync(WebhookRequest webhookRequest) + { + WebhookRequestDto dto = WebhookRequestFactory.CreateDto(webhookRequest); + var result = await Database.InsertAsync(dto); + var id = Convert.ToInt32(result); + webhookRequest.Id = id; + return webhookRequest; + } + + public async Task DeleteAsync(WebhookRequest webhookRequest) + { + Sql sql = Database.SqlContext.Sql() + .Delete() + .Where(x => x.Id == webhookRequest.Id); + + await Database.ExecuteAsync(sql); + } + + public async Task> GetAllAsync() + { + Sql? sql = Database.SqlContext.Sql() + .Select() + .From(); + + List webhookDtos = await Database.FetchAsync(sql); + + return webhookDtos.Select(WebhookRequestFactory.CreateModel); + } + + public async Task UpdateAsync(WebhookRequest webhookRequest) + { + WebhookRequestDto dto = WebhookRequestFactory.CreateDto(webhookRequest); + await Database.UpdateAsync(dto); + return webhookRequest; + } +} diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyIndexValueFactory.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyIndexValueFactory.cs index d3688aba0e..9a5b33a2b0 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyIndexValueFactory.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyIndexValueFactory.cs @@ -38,10 +38,13 @@ internal sealed class BlockValuePropertyIndexValueFactory : _contentTypeService = contentTypeService; } - + [Obsolete("Use non-obsolete overload, scheduled for removal in v14")] protected override IContentType? GetContentTypeOfNestedItem(BlockItemData input) => _contentTypeService.Get(input.ContentTypeKey); + protected override IContentType? GetContentTypeOfNestedItem(BlockItemData input, IDictionary contentTypeDictionary) + => contentTypeDictionary.TryGetValue(input.ContentTypeKey, out var result) ? result : null; + protected override IDictionary GetRawProperty(BlockItemData blockItemData) => blockItemData.RawPropertyValues; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs index 7f78a65d82..93f805f1d4 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs @@ -9,43 +9,40 @@ using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Models.Editors; -using Umbraco.Cms.Core.Models.Entities; -using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; -using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference { - private readonly IEntityService _entityService; private readonly ILogger _logger; - private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; private readonly IPublishedUrlProvider _publishedUrlProvider; private readonly IJsonSerializer _jsonSerializer; + private readonly IContentService _contentService; + private readonly IMediaService _mediaService; public MultiUrlPickerValueEditor( - IEntityService entityService, - IPublishedSnapshotAccessor publishedSnapshotAccessor, ILogger logger, ILocalizedTextService localizedTextService, IShortStringHelper shortStringHelper, DataEditorAttribute attribute, IPublishedUrlProvider publishedUrlProvider, IJsonSerializer jsonSerializer, - IIOHelper ioHelper) + IIOHelper ioHelper, + IContentService contentService, + IMediaService mediaService) : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) { - _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); - _publishedSnapshotAccessor = publishedSnapshotAccessor ?? - throw new ArgumentNullException(nameof(publishedSnapshotAccessor)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _publishedUrlProvider = publishedUrlProvider; _jsonSerializer = jsonSerializer; + _contentService = contentService; + _mediaService = mediaService; } public IEnumerable GetReferences(object? value) @@ -84,26 +81,6 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference { List? links = _jsonSerializer.Deserialize>(value); - List? documentLinks = links?.FindAll(link => - link.Udi != null && link.Udi.EntityType == Constants.UdiEntityType.Document); - List? mediaLinks = links?.FindAll(link => - link.Udi != null && link.Udi.EntityType == Constants.UdiEntityType.Media); - - var entities = new List(); - if (documentLinks?.Count > 0) - { - entities.AddRange( - _entityService.GetAll( - UmbracoObjectTypes.Document, - documentLinks.Select(link => link.Udi!.Guid).ToArray())); - } - - if (mediaLinks?.Count > 0) - { - entities.AddRange( - _entityService.GetAll(UmbracoObjectTypes.Media, mediaLinks.Select(link => link.Udi!.Guid).ToArray())); - } - var result = new List(); if (links is null) { @@ -112,7 +89,7 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference foreach (LinkDto dto in links) { - GuidUdi? udi = null; + GuidUdi? udi = dto.Udi; var icon = "icon-link"; var published = true; var trashed = false; @@ -120,35 +97,30 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference if (dto.Udi != null) { - IUmbracoEntity? entity = entities.Find(e => e.Key == dto.Udi.Guid); - if (entity == null) + if (dto.Udi.EntityType == Constants.UdiEntityType.Document) { - continue; - } + url = _publishedUrlProvider.GetUrl(dto.Udi.Guid, UrlMode.Relative, culture); + IContent? c = _contentService.GetById(dto.Udi.Guid); - IPublishedSnapshot publishedSnapshot = _publishedSnapshotAccessor.GetRequiredPublishedSnapshot(); - if (entity is IDocumentEntitySlim documentEntity) - { - icon = documentEntity.ContentTypeIcon; - published = culture == null - ? documentEntity.Published - : documentEntity.PublishedCultures.Contains(culture); - udi = new GuidUdi(Constants.UdiEntityType.Document, documentEntity.Key); - url = publishedSnapshot.Content?.GetById(entity.Key)?.Url(_publishedUrlProvider) ?? "#"; - trashed = documentEntity.Trashed; + if (c is not null) + { + published = culture == null + ? c.Published + : c.PublishedCultures.Contains(culture); + icon = c.ContentType.Icon; + trashed = c.Trashed; + } } - else if (entity is IContentEntitySlim contentEntity) + else if (dto.Udi.EntityType == Constants.UdiEntityType.Media) { - icon = contentEntity.ContentTypeIcon; - published = !contentEntity.Trashed; - udi = new GuidUdi(Constants.UdiEntityType.Media, contentEntity.Key); - url = publishedSnapshot.Media?.GetById(entity.Key)?.Url(_publishedUrlProvider) ?? "#"; - trashed = contentEntity.Trashed; - } - else - { - // Not supported - continue; + url = _publishedUrlProvider.GetMediaUrl(dto.Udi.Guid, UrlMode.Relative, culture); + IMedia? m = _mediaService.GetById(dto.Udi.Guid); + if (m is not null) + { + published = m.Trashed is false; + icon = m.ContentType.Icon; + trashed = m.Trashed; + } } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyIndexValueFactory.cs b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyIndexValueFactory.cs index 121a40bec9..693c21060b 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyIndexValueFactory.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyIndexValueFactory.cs @@ -39,6 +39,11 @@ internal sealed class NestedContentPropertyIndexValueFactory _contentTypeService = contentTypeService; } + protected override IContentType? GetContentTypeOfNestedItem( + NestedContentPropertyEditor.NestedContentValues.NestedContentRowValue input, IDictionary contentTypeDictionary) + => contentTypeDictionary.Values.FirstOrDefault(x=>x.Alias.Equals(input.ContentTypeAlias)); + + [Obsolete("Use non-obsolete overload, scheduled for removal in v14")] protected override IContentType? GetContentTypeOfNestedItem( NestedContentPropertyEditor.NestedContentValues.NestedContentRowValue input) => _contentTypeService.Get(input.ContentTypeAlias); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NestedPropertyIndexValueFactoryBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/NestedPropertyIndexValueFactoryBase.cs index 94ed0a3e15..a675b38b2c 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/NestedPropertyIndexValueFactoryBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/NestedPropertyIndexValueFactoryBase.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; @@ -42,20 +43,39 @@ internal abstract class NestedPropertyIndexValueFactoryBase bool published) => Handle(deserializedPropertyValue, property, culture, segment, published, Enumerable.Empty()); + [Obsolete("Use the overload that specifies availableCultures, scheduled for removal in v14")] protected override IEnumerable>> Handle( TSerialized deserializedPropertyValue, IProperty property, string? culture, string? segment, bool published, - IEnumerable availableCultures) + IEnumerable availableCultures) => + Handle( + deserializedPropertyValue, + property, + culture, + segment, + published, + Enumerable.Empty(), + StaticServiceProvider.Instance.GetRequiredService().GetAll().ToDictionary(x=>x.Key)); + + + protected override IEnumerable>> Handle( + TSerialized deserializedPropertyValue, + IProperty property, + string? culture, + string? segment, + bool published, + IEnumerable availableCultures, + IDictionary contentTypeDictionary) { var result = new List>>(); var index = 0; foreach (TItem nestedContentRowValue in GetDataItems(deserializedPropertyValue)) { - IContentType? contentType = GetContentTypeOfNestedItem(nestedContentRowValue); + IContentType? contentType = GetContentTypeOfNestedItem(nestedContentRowValue, contentTypeDictionary); if (contentType is null) { @@ -125,6 +145,9 @@ internal abstract class NestedPropertyIndexValueFactoryBase /// /// Gets the content type using the nested item. /// + protected abstract IContentType? GetContentTypeOfNestedItem(TItem nestedItem, IDictionary contentTypeDictionary); + + [Obsolete("Use non-obsolete overload. Scheduled for removal in Umbraco 14.")] protected abstract IContentType? GetContentTypeOfNestedItem(TItem nestedItem); /// diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs index be49e280cb..bd7835caff 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs @@ -29,7 +29,13 @@ internal class RichTextPropertyIndexValueFactory : NestedPropertyIndexValueFacto _logger = logger; } - public IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published, IEnumerable availableCultures) + public new IEnumerable>> GetIndexValues( + IProperty property, + string? culture, + string? segment, + bool published, + IEnumerable availableCultures, + IDictionary contentTypeDictionary) { var val = property.GetValue(culture, segment, published); if (RichTextPropertyEditorHelper.TryParseRichTextEditorValue(val, _jsonSerializer, _logger, out RichTextEditorValue? richTextEditorValue) is false) @@ -38,7 +44,7 @@ internal class RichTextPropertyIndexValueFactory : NestedPropertyIndexValueFacto } // the "blocks values resume" (the combined searchable text values from all blocks) is stored as a string value under the property alias by the base implementation - var blocksIndexValues = base.GetIndexValues(property, culture, segment, published, availableCultures).ToDictionary(pair => pair.Key, pair => pair.Value); + var blocksIndexValues = base.GetIndexValues(property, culture, segment, published, availableCultures, contentTypeDictionary).ToDictionary(pair => pair.Key, pair => pair.Value); var blocksIndexValuesResume = blocksIndexValues.TryGetValue(property.Alias, out IEnumerable? blocksIndexValuesResumeValue) ? blocksIndexValuesResumeValue.FirstOrDefault() as string : null; @@ -57,6 +63,10 @@ internal class RichTextPropertyIndexValueFactory : NestedPropertyIndexValueFacto public IEnumerable>> GetIndexValues(IProperty property, string? culture, string? segment, bool published) => GetIndexValues(property, culture, segment, published, Enumerable.Empty()); + protected override IContentType? GetContentTypeOfNestedItem(BlockItemData nestedItem, IDictionary contentTypeDictionary) + => contentTypeDictionary.TryGetValue(nestedItem.ContentTypeKey, out var result) ? result : null; + + [Obsolete("Use non-obsolete overload. Scheduled for removal in Umbraco 14.")] protected override IContentType? GetContentTypeOfNestedItem(BlockItemData nestedItem) => _contentTypeService.Get(nestedItem.ContentTypeKey); 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 0d101cf106..22177c3a63 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs @@ -120,6 +120,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 2b2d0dec2e..1d9319db68 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.Infrastructure/Services/Implement/WebhookFiringService.cs b/src/Umbraco.Infrastructure/Services/Implement/WebhookFiringService.cs index cf09c0d3a2..b107d82999 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/WebhookFiringService.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/WebhookFiringService.cs @@ -1,74 +1,16 @@ -using System.Net.Http.Headers; -using System.Text; -using Microsoft.Extensions.Options; -using Umbraco.Cms.Core.Configuration.Models; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Infrastructure.Services.Implement; public class WebhookFiringService : IWebhookFiringService { - private readonly IJsonSerializer _jsonSerializer; - private readonly WebhookSettings _webhookSettings; - private readonly IWebhookLogService _webhookLogService; - private readonly IWebhookLogFactory _webhookLogFactory; + private readonly IWebhookRequestService _webhookRequestService; - public WebhookFiringService( - IJsonSerializer jsonSerializer, - IOptions webhookSettings, - IWebhookLogService webhookLogService, - IWebhookLogFactory webhookLogFactory) - { - _jsonSerializer = jsonSerializer; - _webhookLogService = webhookLogService; - _webhookLogFactory = webhookLogFactory; - _webhookSettings = webhookSettings.Value; - } + public WebhookFiringService(IWebhookRequestService webhookRequestService) => _webhookRequestService = webhookRequestService; - // TODO: Add queing instead of processing directly in thread - // as this just makes save and publish longer - public async Task FireAsync(Webhook webhook, string eventName, object? payload, CancellationToken cancellationToken) - { - for (var retry = 0; retry < _webhookSettings.MaximumRetries; retry++) - { - HttpResponseMessage response = await SendRequestAsync(webhook, eventName, payload, retry, cancellationToken); - - if (response.IsSuccessStatusCode) - { - return; - } - } - } - - private async Task SendRequestAsync(Webhook webhook, string eventName, object? payload, int retryCount, CancellationToken cancellationToken) - { - using var httpClient = new HttpClient(); - - var serializedObject = _jsonSerializer.Serialize(payload); - var stringContent = new StringContent(serializedObject, Encoding.UTF8, "application/json"); - stringContent.Headers.TryAddWithoutValidation("Umb-Webhook-Event", eventName); - - foreach (KeyValuePair header in webhook.Headers) - { - stringContent.Headers.TryAddWithoutValidation(header.Key, header.Value); - } - - HttpResponseMessage response = await httpClient.PostAsync(webhook.Url, stringContent, cancellationToken); - - var webhookResponseModel = new WebhookResponseModel - { - HttpResponseMessage = response, - RetryCount = retryCount, - }; - - - WebhookLog log = await _webhookLogFactory.CreateAsync(eventName, webhookResponseModel, webhook, cancellationToken); - await _webhookLogService.CreateAsync(log); - - return response; - } + public async Task FireAsync(Webhook webhook, string eventAlias, object? payload, CancellationToken cancellationToken) => + await _webhookRequestService.CreateAsync(webhook.Key, eventAlias, payload); } diff --git a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj index 14e82f73a7..75678035c3 100644 --- a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj +++ b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj @@ -13,7 +13,7 @@ - + @@ -25,18 +25,22 @@ - + + - + - - + + + + + diff --git a/src/Umbraco.PublishedCache.NuCache/Persistence/NuCacheContentRepository.cs b/src/Umbraco.PublishedCache.NuCache/Persistence/NuCacheContentRepository.cs index af8c46821f..f54158dac1 100644 --- a/src/Umbraco.PublishedCache.NuCache/Persistence/NuCacheContentRepository.cs +++ b/src/Umbraco.PublishedCache.NuCache/Persistence/NuCacheContentRepository.cs @@ -224,7 +224,7 @@ AND cmsContentNu.nodeId IS NULL IContentCacheDataSerializer serializer = _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document); - IEnumerable dtos = GetContentNodeDtos(sql); + IEnumerable dtos = GetContentNodeDtos(sql, Constants.ObjectTypes.Document); foreach (ContentSourceDto row in dtos) { @@ -242,7 +242,7 @@ AND cmsContentNu.nodeId IS NULL IContentCacheDataSerializer serializer = _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document); - IEnumerable dtos = GetContentNodeDtos(sql); + IEnumerable dtos = GetContentNodeDtos(sql, Constants.ObjectTypes.Document); foreach (ContentSourceDto row in dtos) { @@ -265,7 +265,7 @@ AND cmsContentNu.nodeId IS NULL IContentCacheDataSerializer serializer = _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document); - IEnumerable dtos = GetContentNodeDtos(sql); + IEnumerable dtos = GetContentNodeDtos(sql, Constants.ObjectTypes.Document); foreach (ContentSourceDto row in dtos) { @@ -301,7 +301,7 @@ AND cmsContentNu.nodeId IS NULL IContentCacheDataSerializer serializer = _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Media); - IEnumerable dtos = GetContentNodeDtos(sql); + IEnumerable dtos = GetContentNodeDtos(sql, Constants.ObjectTypes.Media); foreach (ContentSourceDto row in dtos) { @@ -319,7 +319,7 @@ AND cmsContentNu.nodeId IS NULL IContentCacheDataSerializer serializer = _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Media); - IEnumerable dtos = GetContentNodeDtos(sql); + IEnumerable dtos = GetContentNodeDtos(sql, Constants.ObjectTypes.Media); foreach (ContentSourceDto row in dtos) { @@ -342,7 +342,7 @@ AND cmsContentNu.nodeId IS NULL IContentCacheDataSerializer serializer = _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Media); - IEnumerable dtos = GetContentNodeDtos(sql); + IEnumerable dtos = GetContentNodeDtos(sql, Constants.ObjectTypes.Media); foreach (ContentSourceDto row in dtos) { @@ -990,7 +990,7 @@ WHERE cmsContentNu.nodeId IN ( return s; } - private IEnumerable GetContentNodeDtos(Sql sql) + private IEnumerable GetContentNodeDtos(Sql sql, Guid nodeObjectType) { // We need to page here. We don't want to iterate over every single row in one connection cuz this can cause an SQL Timeout. // We also want to read with a db reader and not load everything into memory, QueryPaged lets us do that. @@ -1000,7 +1000,7 @@ WHERE cmsContentNu.nodeId IN ( { // Use a more efficient COUNT query Sql? sqlCountQuery = SqlContentSourcesCount() - .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document)); + .Append(SqlObjectTypeNotTrashed(SqlContext, nodeObjectType)); Sql? sqlCount = SqlContext.Sql("SELECT COUNT(*) FROM (").Append(sqlCountQuery).Append(") npoco_tbl"); 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/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj b/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj index 5eb8a7a62e..a61eba6c79 100644 --- a/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj +++ b/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Umbraco.Web.BackOffice/Controllers/ExamineManagementController.cs b/src/Umbraco.Web.BackOffice/Controllers/ExamineManagementController.cs index 2e48fbf25d..d41ccf684e 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/ExamineManagementController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/ExamineManagementController.cs @@ -1,6 +1,7 @@ using Examine; using Examine.Search; using Lucene.Net.QueryParsers.Classic; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Umbraco.Cms.Core; @@ -8,11 +9,13 @@ using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Infrastructure.Examine; using Umbraco.Cms.Web.Common.Attributes; +using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; using SearchResult = Umbraco.Cms.Core.Models.ContentEditing.SearchResult; namespace Umbraco.Cms.Web.BackOffice.Controllers; +[Authorize(Policy = AuthorizationPolicies.SectionAccessSettings)] [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] public class ExamineManagementController : UmbracoAuthorizedJsonController { diff --git a/src/Umbraco.Web.BackOffice/Controllers/RedirectUrlManagementController.cs b/src/Umbraco.Web.BackOffice/Controllers/RedirectUrlManagementController.cs index d87398d574..a5e5e3b447 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/RedirectUrlManagementController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/RedirectUrlManagementController.cs @@ -2,6 +2,7 @@ // See LICENSE for more details. using System.Security; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -14,10 +15,12 @@ using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Web.Common.Attributes; +using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Extensions; namespace Umbraco.Cms.Web.BackOffice.Controllers; +[Authorize(Policy = AuthorizationPolicies.SectionAccessSettings)] [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] public class RedirectUrlManagementController : UmbracoAuthorizedApiController { diff --git a/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs b/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs index 9be5372ce5..61f583d8eb 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs @@ -1,36 +1,45 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Core.Webhooks; +using Umbraco.Cms.Web.BackOffice.Services; using Umbraco.Cms.Web.Common.Attributes; +using Umbraco.Cms.Web.Common.Authorization; using Umbraco.Cms.Web.Common.Models; namespace Umbraco.Cms.Web.BackOffice.Controllers; [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] +[Authorize(Policy = AuthorizationPolicies.TreeAccessWebhooks)] public class WebhookController : UmbracoAuthorizedJsonController { - private readonly IWebHookService _webHookService; + private readonly IWebhookService _webhookService; private readonly IUmbracoMapper _umbracoMapper; private readonly WebhookEventCollection _webhookEventCollection; private readonly IWebhookLogService _webhookLogService; + private readonly IWebhookPresentationFactory _webhookPresentationFactory; - public WebhookController(IWebHookService webHookService, IUmbracoMapper umbracoMapper, WebhookEventCollection webhookEventCollection, IWebhookLogService webhookLogService) + public WebhookController(IWebhookService webhookService, IUmbracoMapper umbracoMapper, WebhookEventCollection webhookEventCollection, IWebhookLogService webhookLogService, IWebhookPresentationFactory webhookPresentationFactory) { - _webHookService = webHookService; + _webhookService = webhookService; _umbracoMapper = umbracoMapper; _webhookEventCollection = webhookEventCollection; _webhookLogService = webhookLogService; + _webhookPresentationFactory = webhookPresentationFactory; } [HttpGet] public async Task GetAll(int skip = 0, int take = int.MaxValue) { - PagedModel webhooks = await _webHookService.GetAllAsync(skip, take); + PagedModel webhooks = await _webhookService.GetAllAsync(skip, take); - List webhookViewModels = _umbracoMapper.MapEnumerable(webhooks.Items); + IEnumerable webhookViewModels = webhooks.Items.Select(_webhookPresentationFactory.Create); return Ok(webhookViewModels); } @@ -38,36 +47,33 @@ public class WebhookController : UmbracoAuthorizedJsonController [HttpPut] public async Task Update(WebhookViewModel webhookViewModel) { - Webhook updateModel = _umbracoMapper.Map(webhookViewModel)!; + Webhook webhook = _umbracoMapper.Map(webhookViewModel)!; - await _webHookService.UpdateAsync(updateModel); - - return Ok(); + Attempt result = await _webhookService.UpdateAsync(webhook); + return result.Success ? Ok(_webhookPresentationFactory.Create(webhook)) : WebhookOperationStatusResult(result.Status); } [HttpPost] public async Task Create(WebhookViewModel webhookViewModel) { Webhook webhook = _umbracoMapper.Map(webhookViewModel)!; - await _webHookService.CreateAsync(webhook); - - return Ok(); + Attempt result = await _webhookService.CreateAsync(webhook); + return result.Success ? Ok(_webhookPresentationFactory.Create(webhook)) : WebhookOperationStatusResult(result.Status); } [HttpGet] public async Task GetByKey(Guid key) { - Webhook? webhook = await _webHookService.GetAsync(key); + Webhook? webhook = await _webhookService.GetAsync(key); - return webhook is null ? NotFound() : Ok(webhook); + return webhook is null ? NotFound() : Ok(_webhookPresentationFactory.Create(webhook)); } [HttpDelete] public async Task Delete(Guid key) { - await _webHookService.DeleteAsync(key); - - return Ok(); + Attempt result = await _webhookService.DeleteAsync(key); + return result.Success ? Ok() : WebhookOperationStatusResult(result.Status); } [HttpGet] @@ -87,4 +93,15 @@ public class WebhookController : UmbracoAuthorizedJsonController Items = mappedLogs, }); } + + private IActionResult WebhookOperationStatusResult(WebhookOperationStatus status) => + status switch + { + WebhookOperationStatus.CancelledByNotification => ValidationProblem(new SimpleNotificationModel(new BackOfficeNotification[] + { + new("Cancelled by notification", "The operation was cancelled by a notification", NotificationStyle.Error), + })), + WebhookOperationStatus.NotFound => NotFound("Could not find the webhook"), + _ => StatusCode(StatusCodes.Status500InternalServerError), + }; } diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs index 2ba1025a6e..961e98c415 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs @@ -47,7 +47,7 @@ public static partial class UmbracoBuilderExtensions .AddMvcAndRazor(configureMvc) .AddWebServer() .AddPreviewSupport() - .AddHostedServices() + .AddRecurringBackgroundJobs() .AddNuCache() .AddDistributedCache() .TryAddModelsBuilderDashboard() @@ -122,6 +122,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddUnique(); builder.Services.AddSingleton(); builder.Services.AddTransient(); + builder.Services.AddUnique(); return builder; } diff --git a/src/Umbraco.Web.BackOffice/Mapping/WebhookMapDefinition.cs b/src/Umbraco.Web.BackOffice/Mapping/WebhookMapDefinition.cs index c797ce67ee..8d9982d0c6 100644 --- a/src/Umbraco.Web.BackOffice/Mapping/WebhookMapDefinition.cs +++ b/src/Umbraco.Web.BackOffice/Mapping/WebhookMapDefinition.cs @@ -10,7 +10,6 @@ public class WebhookMapDefinition : IMapDefinition public void DefineMaps(IUmbracoMapper mapper) { mapper.Define((_, _) => new Webhook(string.Empty), Map); - mapper.Define((_, _) => new WebhookViewModel(), Map); mapper.Define((_, _) => new WebhookEventViewModel(), Map); mapper.Define((_, _) => new WebhookLogViewModel(), Map); } @@ -19,7 +18,7 @@ public class WebhookMapDefinition : IMapDefinition private void Map(WebhookViewModel source, Webhook target, MapperContext context) { target.ContentTypeKeys = source.ContentTypeKeys; - target.Events = source.Events; + target.Events = source.Events.Select(x => x.Alias).ToArray(); target.Url = source.Url; target.Enabled = source.Enabled; target.Key = source.Key ?? Guid.NewGuid(); @@ -27,24 +26,18 @@ public class WebhookMapDefinition : IMapDefinition } // Umbraco.Code.MapAll - private void Map(Webhook source, WebhookViewModel target, MapperContext context) + private void Map(IWebhookEvent source, WebhookEventViewModel target, MapperContext context) { - target.ContentTypeKeys = source.ContentTypeKeys; - target.Events = source.Events; - target.Url = source.Url; - target.Enabled = source.Enabled; - target.Key = source.Key; - target.Headers = source.Headers; + target.EventName = source.EventName; + target.EventType = source.EventType; + target.Alias = source.Alias; } - // Umbraco.Code.MapAll - private void Map(IWebhookEvent source, WebhookEventViewModel target, MapperContext context) => target.EventName = source.EventName; - // Umbraco.Code.MapAll private void Map(WebhookLog source, WebhookLogViewModel target, MapperContext context) { target.Date = source.Date; - target.EventName = source.EventName; + target.EventAlias = source.EventAlias; target.Key = source.Key; target.RequestBody = source.RequestBody ?? string.Empty; target.ResponseBody = source.ResponseBody; diff --git a/src/Umbraco.Web.BackOffice/Services/IWebhookPresentationFactory.cs b/src/Umbraco.Web.BackOffice/Services/IWebhookPresentationFactory.cs new file mode 100644 index 0000000000..5d94607998 --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Services/IWebhookPresentationFactory.cs @@ -0,0 +1,10 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Web.Common.Models; + +namespace Umbraco.Cms.Web.BackOffice.Services; + +[Obsolete("Will be moved to a new namespace in V14")] +public interface IWebhookPresentationFactory +{ + WebhookViewModel Create(Webhook webhook); +} diff --git a/src/Umbraco.Web.BackOffice/Services/WebhookPresentationFactory.cs b/src/Umbraco.Web.BackOffice/Services/WebhookPresentationFactory.cs new file mode 100644 index 0000000000..ef4e052596 --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Services/WebhookPresentationFactory.cs @@ -0,0 +1,41 @@ +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Webhooks; +using Umbraco.Cms.Web.Common.Models; + +namespace Umbraco.Cms.Web.BackOffice.Services; + +internal class WebhookPresentationFactory : IWebhookPresentationFactory +{ + private readonly WebhookEventCollection _webhookEventCollection; + + public WebhookPresentationFactory(WebhookEventCollection webhookEventCollection) => _webhookEventCollection = webhookEventCollection; + + public WebhookViewModel Create(Webhook webhook) + { + var target = new WebhookViewModel + { + ContentTypeKeys = webhook.ContentTypeKeys, + Events = webhook.Events.Select(Create).ToArray(), + Url = webhook.Url, + Enabled = webhook.Enabled, + Id = webhook.Id, + Key = webhook.Key, + Headers = webhook.Headers, + }; + + return target; + } + + private WebhookEventViewModel Create(string alias) + { + IWebhookEvent? webhookEvent = _webhookEventCollection.FirstOrDefault(x => x.Alias == alias); + + return new WebhookEventViewModel + { + EventName = webhookEvent?.EventName ?? alias, + EventType = webhookEvent?.EventType ?? Constants.WebhookEvents.Types.Other, + Alias = alias, + }; + } +} diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 5a1e7623d5..0f2a97899e 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Serilog.Extensions.Logging; @@ -39,6 +40,9 @@ using Umbraco.Cms.Core.Telemetry; using Umbraco.Cms.Core.Templates; using Umbraco.Cms.Core.Web; using Umbraco.Cms.Core.WebAssets; +using Umbraco.Cms.Infrastructure.BackgroundJobs; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration; using Umbraco.Cms.Infrastructure.DependencyInjection; using Umbraco.Cms.Infrastructure.HostedServices; using Umbraco.Cms.Infrastructure.HostedServices.ServerRegistration; @@ -179,6 +183,7 @@ public static partial class UmbracoBuilderExtensions /// /// Add Umbraco hosted services /// + [Obsolete("Use AddRecurringBackgroundJobs instead")] public static IUmbracoBuilder AddHostedServices(this IUmbracoBuilder builder) { builder.Services.AddHostedService(); @@ -195,6 +200,37 @@ public static partial class UmbracoBuilderExtensions new ReportSiteTask( provider.GetRequiredService>(), provider.GetRequiredService())); + + + return builder; + } + + /// + /// Add Umbraco recurring background jobs + /// + public static IUmbracoBuilder AddRecurringBackgroundJobs(this IUmbracoBuilder builder) + { + // Add background jobs + builder.Services.AddRecurringBackgroundJob(); + builder.Services.AddRecurringBackgroundJob(); + builder.Services.AddRecurringBackgroundJob(); + builder.Services.AddRecurringBackgroundJob(); + builder.Services.AddRecurringBackgroundJob(); + builder.Services.AddRecurringBackgroundJob(); + builder.Services.AddRecurringBackgroundJob(); + builder.Services.AddRecurringBackgroundJob(); + builder.Services.AddRecurringBackgroundJob(); + builder.Services.AddRecurringBackgroundJob(provider => + new ReportSiteJob( + provider.GetRequiredService>(), + provider.GetRequiredService())); + + + builder.Services.AddHostedService(); + builder.Services.AddSingleton(RecurringBackgroundJobHostedService.CreateHostedServiceFactory); + builder.Services.AddHostedService(); + + return builder; } diff --git a/src/Umbraco.Web.Common/Extensions/HttpRequestExtensions.cs b/src/Umbraco.Web.Common/Extensions/HttpRequestExtensions.cs index f12e469b6b..d60bb1249b 100644 --- a/src/Umbraco.Web.Common/Extensions/HttpRequestExtensions.cs +++ b/src/Umbraco.Web.Common/Extensions/HttpRequestExtensions.cs @@ -45,6 +45,9 @@ public static class HttpRequestExtensions public static string? ClientCulture(this HttpRequest request) => request.Headers.TryGetValue("X-UMB-CULTURE", out StringValues values) ? values[0] : null; + public static string? ClientSegment(this HttpRequest request) + => request.Headers.TryGetValue("X-UMB-SEGMENT", out StringValues values) ? values[0] : null; + /// /// Determines if a request is local. /// diff --git a/src/Umbraco.Web.Common/Middleware/UmbracoRequestMiddleware.cs b/src/Umbraco.Web.Common/Middleware/UmbracoRequestMiddleware.cs index f5b4f06a69..4783e9d3db 100644 --- a/src/Umbraco.Web.Common/Middleware/UmbracoRequestMiddleware.cs +++ b/src/Umbraco.Web.Common/Middleware/UmbracoRequestMiddleware.cs @@ -104,7 +104,7 @@ public class UmbracoRequestMiddleware : IMiddleware // Also MiniProfiler.Current becomes null if it is handled by the event aggregator due to async/await _profiler?.UmbracoApplicationBeginRequest(context, _runtimeState.Level); - _variationContextAccessor.VariationContext ??= new VariationContext(_defaultCultureAccessor.DefaultCulture); + _variationContextAccessor.VariationContext ??= new VariationContext(context.Request.ClientCulture() ?? _defaultCultureAccessor.DefaultCulture, context.Request.ClientSegment()); UmbracoContextReference umbracoContextReference = _umbracoContextFactory.EnsureUmbracoContext(); Uri? currentApplicationUrl = GetApplicationUrlFromCurrentRequest(context.Request); diff --git a/src/Umbraco.Web.Common/Models/WebhookEventViewModel.cs b/src/Umbraco.Web.Common/Models/WebhookEventViewModel.cs index 441a367429..05243d4eb6 100644 --- a/src/Umbraco.Web.Common/Models/WebhookEventViewModel.cs +++ b/src/Umbraco.Web.Common/Models/WebhookEventViewModel.cs @@ -1,4 +1,5 @@ using System.Runtime.Serialization; +using Umbraco.Cms.Core.Webhooks; namespace Umbraco.Cms.Web.Common.Models; @@ -7,4 +8,10 @@ public class WebhookEventViewModel { [DataMember(Name = "eventName")] public string EventName { get; set; } = string.Empty; + + [DataMember(Name = "eventType")] + public string EventType { get; set; } = string.Empty; + + [DataMember(Name = "alias")] + public string Alias { get; set; } = string.Empty; } diff --git a/src/Umbraco.Web.Common/Models/WebhookLogViewModel.cs b/src/Umbraco.Web.Common/Models/WebhookLogViewModel.cs index f9bf6762f8..f63282b7cb 100644 --- a/src/Umbraco.Web.Common/Models/WebhookLogViewModel.cs +++ b/src/Umbraco.Web.Common/Models/WebhookLogViewModel.cs @@ -17,8 +17,8 @@ public class WebhookLogViewModel [DataMember(Name = "date")] public DateTime Date { get; set; } - [DataMember(Name = "eventName")] - public string EventName { get; set; } = string.Empty; + [DataMember(Name = "eventAlias")] + public string EventAlias { get; set; } = string.Empty; [DataMember(Name = "url")] public string Url { get; set; } = string.Empty; diff --git a/src/Umbraco.Web.Common/Models/WebhookViewModel.cs b/src/Umbraco.Web.Common/Models/WebhookViewModel.cs index a0efff398b..a1e581ff22 100644 --- a/src/Umbraco.Web.Common/Models/WebhookViewModel.cs +++ b/src/Umbraco.Web.Common/Models/WebhookViewModel.cs @@ -1,10 +1,13 @@ -using System.Runtime.Serialization; +using System.Runtime.Serialization; namespace Umbraco.Cms.Web.Common.Models; [DataContract] public class WebhookViewModel { + [DataMember(Name = "id")] + public int Id { get; set; } + [DataMember(Name = "key")] public Guid? Key { get; set; } @@ -12,7 +15,7 @@ public class WebhookViewModel public string Url { get; set; } = string.Empty; [DataMember(Name = "events")] - public string[] Events { get; set; } = Array.Empty(); + public WebhookEventViewModel[] Events { get; set; } = Array.Empty(); [DataMember(Name = "contentTypeKeys")] public Guid[] ContentTypeKeys { get; set; } = Array.Empty(); diff --git a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj index 0b26d82837..dc0d0dc6fa 100644 --- a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj +++ b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj @@ -13,6 +13,8 @@ + + diff --git a/src/Umbraco.Web.UI.Client/src/installer/steps/permissionsreport.html b/src/Umbraco.Web.UI.Client/src/installer/steps/permissionsreport.html index f3067e8fb6..ae3d4ed49a 100644 --- a/src/Umbraco.Web.UI.Client/src/installer/steps/permissionsreport.html +++ b/src/Umbraco.Web.UI.Client/src/installer/steps/permissionsreport.html @@ -3,7 +3,7 @@

In order to run Umbraco, you'll need to update your permission settings. Detailed information about the correct file and folder permissions for Umbraco can be found - here. + here.

The following report list the permissions that are currently failing. Once the permissions are fixed press the 'Go back' button to restart the installation. diff --git a/src/Umbraco.Web.UI.Client/src/less/property-editors.less b/src/Umbraco.Web.UI.Client/src/less/property-editors.less index d68a48c702..f24365a806 100644 --- a/src/Umbraco.Web.UI.Client/src/less/property-editors.less +++ b/src/Umbraco.Web.UI.Client/src/less/property-editors.less @@ -2,7 +2,11 @@ // Container styles // -------------------------------------------------- .umb-property-editor { - width: 100%; + width: 100%; + + &--limit-width { + .umb-property-editor--limit-width(); + } } .umb-property-editor-tiny { diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/eventpicker/eventpicker.controller.js b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/eventpicker/eventpicker.controller.js index 67bea3c07b..2e19a102a2 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/eventpicker/eventpicker.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/eventpicker/eventpicker.controller.js @@ -1,7 +1,7 @@ (function () { "use strict"; - function LanguagePickerController($scope, languageResource, localizationService, webhooksResource) { + function EventPickerController($scope, languageResource, localizationService, webhooksResource) { var vm = this; @@ -38,10 +38,10 @@ .then((data) => { let selectedEvents = []; data.forEach(function (event) { - let eventObject = { name: event.eventName, selected: false} - vm.events.push(eventObject); - if($scope.model.selectedEvents && $scope.model.selectedEvents.includes(eventObject.name)){ - selectedEvents.push(eventObject); + event.selected = false; + vm.events.push(event); + if($scope.model.selectedEvents && $scope.model.selectedEvents.some(x => x.alias === event.alias)){ + selectedEvents.push(event); } }); @@ -55,19 +55,15 @@ if (!event.selected) { event.selected = true; $scope.model.selection.push(event); + // Only filter if we have not selected an item yet. if($scope.model.selection.length === 1){ - if(event.name.toLowerCase().includes("content")){ - vm.events = vm.events.filter(event => event.name.toLowerCase().includes("content")); - } - else if (event.name.toLowerCase().includes("media")){ - vm.events = vm.events.filter(event => event.name.toLowerCase().includes("media")); - } + vm.events = vm.events.filter(x => x.eventType === event.eventType); } - } else { - + } + else { $scope.model.selection.forEach(function (selectedEvent, index) { - if (selectedEvent.name === event.name) { + if (selectedEvent.alias === event.alias) { event.selected = false; $scope.model.selection.splice(index, 1); } @@ -75,6 +71,7 @@ if($scope.model.selection.length === 0){ vm.events = []; + $scope.model.selectedEvents = []; getAllEvents(); } } @@ -82,7 +79,6 @@ function submit(model) { if ($scope.model.submit) { - $scope.model.selection = $scope.model.selection.map((item) => item.name) $scope.model.submit(model); } } @@ -97,6 +93,6 @@ } - angular.module("umbraco").controller("Umbraco.Editors.EventPickerController", LanguagePickerController); + angular.module("umbraco").controller("Umbraco.Editors.EventPickerController", EventPickerController); })(); diff --git a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/eventpicker/eventpicker.html b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/eventpicker/eventpicker.html index d4033784ed..e65a5f767f 100644 --- a/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/eventpicker/eventpicker.html +++ b/src/Umbraco.Web.UI.Client/src/views/common/infiniteeditors/eventpicker/eventpicker.html @@ -20,7 +20,7 @@ - {{ event.name }} + {{ event.eventName }} diff --git a/src/Umbraco.Web.UI.Client/src/views/languages/edit.controller.js b/src/Umbraco.Web.UI.Client/src/views/languages/edit.controller.js index b545bd2a7c..b2b5f487be 100644 --- a/src/Umbraco.Web.UI.Client/src/views/languages/edit.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/languages/edit.controller.js @@ -1,7 +1,7 @@ (function () { "use strict"; - function LanguagesEditController($scope, $q, $timeout, $location, $routeParams, overlayService, navigationService, notificationsService, localizationService, languageResource, contentEditingHelper, formHelper, eventsService) { + function LanguagesEditController($scope, $q, $timeout, $location, $routeParams, overlayService, navigationService, notificationsService, localizationService, languageResource, formHelper, eventsService) { var vm = this; vm.isNew = false; diff --git a/src/Umbraco.Web.UI.Client/src/views/languages/overview.controller.js b/src/Umbraco.Web.UI.Client/src/views/languages/overview.controller.js index ef46bcf84d..ef30ca6d46 100644 --- a/src/Umbraco.Web.UI.Client/src/views/languages/overview.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/languages/overview.controller.js @@ -11,6 +11,7 @@ vm.addLanguage = addLanguage; vm.editLanguage = editLanguage; vm.deleteLanguage = deleteLanguage; + vm.getLanguageById = function(id) { for (var i = 0; i < vm.languages.length; i++) { if (vm.languages[i].id === id) { diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.controller.js b/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.controller.js new file mode 100644 index 0000000000..93bbd842b0 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.controller.js @@ -0,0 +1,330 @@ +(function () { + "use strict"; + + function WebhooksEditController($scope, $q, $timeout, $location, $routeParams, editorService, eventsService, navigationService, notificationsService, localizationService, contentTypeResource, mediaTypeResource, memberTypeResource, webhooksResource, formHelper) { + + const vm = this; + + vm.isNew = false; + vm.showIdentifier = true; + + vm.contentTypes = []; + vm.webhook = {}; + vm.breadcrumbs = []; + vm.labels = {}; + vm.save = save; + vm.back = back; + vm.goToPage = goToPage; + + vm.clearContentType = clearContentType; + vm.clearEvent = clearEvent; + vm.openContentTypePicker = openContentTypePicker; + vm.openEventPicker = openEventPicker; + vm.openCreateHeader = openCreateHeader; + vm.removeHeader = removeHeader; + + function init() { + vm.loading = true; + + let promises = []; + + // Localize labels + promises.push(localizationService.localizeMany([ + "treeHeaders_webhooks", + "webhooks_addWebhook", + "defaultdialogs_confirmSure", + "defaultdialogs_editWebhook" + ]).then(function (values) { + vm.labels.webhooks = values[0]; + vm.labels.addWebhook = values[1]; + vm.labels.areYouSure = values[2]; + vm.labels.editWebhook = values[3]; + + if ($routeParams.create) { + vm.isNew = true; + vm.showIdentifier = false; + vm.webhook.name = vm.labels.addWebhook; + vm.webhook.enabled = true; + } + })); + + // Load events + promises.push(loadEvents()); + + if (!$routeParams.create) { + + promises.push(webhooksResource.getByKey($routeParams.id).then(webhook => { + + vm.webhook = webhook; + vm.webhook.name = vm.labels.editWebhook; + + const eventType = vm.webhook ? vm.webhook.events[0].eventType.toLowerCase() : null; + const contentTypes = webhook.contentTypeKeys.map(x => ({ key: x })); + + getEntities(contentTypes, eventType); + + makeBreadcrumbs(); + })); + } + + $q.all(promises).then(() => { + if ($routeParams.create) { + $scope.$emit("$changeTitle", vm.labels.addWebhook); + } else { + $scope.$emit("$changeTitle", vm.labels.editWebhook + ": " + vm.webhook.key); + } + + vm.loading = false; + }); + + // Activate tree node + $timeout(function () { + navigationService.syncTree({ tree: $routeParams.tree, path: [-1], activate: true }); + }); + } + + function openEventPicker() { + + const dialog = { + selectedEvents: vm.webhook.events, + submit(model) { + vm.webhook.events = model.selection; + editorService.close(); + }, + close() { + editorService.close(); + } + }; + + localizationService.localize("defaultdialogs_selectEvent").then(value => { + dialog.title = value; + editorService.eventPicker(dialog); + }); + } + + function openContentTypePicker() { + const eventType = vm.webhook ? vm.webhook.events[0].eventType.toLowerCase() : null; + + const editor = { + multiPicker: true, + filterCssClass: "not-allowed not-published", + filter: function (item) { + // filter out folders (containers), element types (for content) and already selected items + return item.nodeType === "container"; // || item.metaData.isElement || !!_.findWhere(vm.itemTypes, { udi: item.udi }); + }, + submit(model) { + getEntities(model.selection, eventType); + vm.webhook.contentTypeKeys = model.selection.map(item => item.key); + editorService.close(); + }, + close() { + editorService.close(); + } + }; + + switch (eventType.toLowerCase()) { + case "content": + editorService.contentTypePicker(editor); + break; + case "media": + editorService.mediaTypePicker(editor); + break; + case "member": + editorService.memberTypePicker(editor); + break; + } + } + + function getEntities(selection, eventType) { + let resource; + switch (eventType.toCamelCase()) { + case "content": + resource = contentTypeResource; + break; + case "media": + resource = mediaTypeResource; + break; + case "member": + resource = memberTypeResource; + break; + default: + return; + } + + selection.forEach(entity => { + resource.getById(entity.key) + .then(data => { + if (!vm.contentTypes.some(x => x.key === data.key)) { + vm.contentTypes.push(data); + } + }); + }); + } + + function loadEvents() { + return webhooksResource.getAllEvents() + .then(data => { + vm.events = data; + }); + } + + function clearContentType(contentTypeKey) { + if (Utilities.isArray(vm.webhook.contentTypeKeys)) { + vm.webhook.contentTypeKeys = vm.webhook.contentTypeKeys.filter(x => x !== contentTypeKey); + } + if (Utilities.isArray(vm.contentTypes)) { + vm.contentTypes = vm.contentTypes.filter(x => x.key !== contentTypeKey); + } + } + + function clearEvent(event) { + if (Utilities.isArray(vm.webhook.events)) { + vm.webhook.events = vm.webhook.events.filter(x => x !== event); + } + + if (Utilities.isArray(vm.contentTypes)) { + vm.events = vm.events.filter(x => x.key !== event); + } + } + + function openCreateHeader() { + + const dialog = { + view: "views/webhooks/overlays/header.html", + size: 'small', + position: 'right', + submit(model) { + if (!vm.webhook.headers) { + vm.webhook.headers = {}; + } + vm.webhook.headers[model.key] = model.value; + editorService.close(); + }, + close() { + editorService.close(); + } + }; + + localizationService.localize("webhooks_createHeader").then(value => { + dialog.title = value; + editorService.open(dialog); + }); + } + + function removeHeader(key) { + delete vm.webhook.headers[key]; + } + + function save() { + + if (!formHelper.submitForm({ scope: $scope })) { + return; + } + + saveWebhook(); + } + + function saveWebhook() { + + if (vm.isNew) { + webhooksResource.create(vm.webhook) + .then(webhook => { + + formHelper.resetForm({ scope: $scope }); + + vm.webhook = webhook; + vm.webhook.name = vm.labels.editWebhook; + + vm.saveButtonState = "success"; + + $scope.$emit("$changeTitle", vm.labels.editWebhook + ": " + vm.webhook.key); + + localizationService.localize("speechBubbles_webhookSaved").then(value => { + notificationsService.success(value); + }); + + // Emit event when language is created or updated/saved + eventsService.emit("editors.webhooks.webhookSaved", { + webhook: webhook, + isNew: vm.isNew + }); + + vm.isNew = false; + vm.showIdentifier = true; + + }, x => { + let errorMessage = undefined; + if (x.data.ModelState) { + errorMessage = `Message: ${Object.values(x.data.ModelState).flat().join(' ')}`; + } + handleSubmissionError(x, `Error saving webhook. ${errorMessage ?? ''}`); + }); + } + else { + webhooksResource.update(vm.webhook) + .then(webhook => { + + formHelper.resetForm({ scope: $scope }); + + vm.webhook = webhook; + vm.webhook.name = vm.labels.editWebhook; + + vm.saveButtonState = "success"; + + $scope.$emit("$changeTitle", vm.labels.editWebhook + ": " + vm.webhook.key); + + localizationService.localize("speechBubbles_webhookSaved").then(value => { + notificationsService.success(value); + }); + + // Emit event when language is created or updated/saved + eventsService.emit("editors.webhooks.webhookSaved", { + webhook: webhook, + isNew: vm.isNew + }); + + vm.isNew = false; + vm.showIdentifier = true; + + }, x => { + let errorMessage = undefined; + if (x.data.ModelState) { + errorMessage = `Message: ${Object.values(x.data.ModelState).flat().join(' ')}`; + } + handleSubmissionError(x, `Error saving webhook. ${errorMessage ?? ''}`); + }); + } + } + + function handleSubmissionError(err, errorMessage) { + notificationsService.error(errorMessage); + vm.saveButtonState = 'error'; + formHelper.resetForm({ scope: $scope, hasErrors: true }); + formHelper.handleError(err); + } + + function back() { + $location.path("settings/webhooks/overview"); + } + + function goToPage(ancestor) { + $location.path(ancestor.path); + } + + function makeBreadcrumbs() { + vm.breadcrumbs = [ + { + "name": vm.labels.webhooks, + "path": "/settings/webhooks/overview" + }, + { + "name": vm.webhook.name + } + ]; + } + + init(); + } + + angular.module("umbraco").controller("Umbraco.Editors.Webhooks.EditController", WebhooksEditController); +})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html b/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html new file mode 100644 index 0000000000..66f93c244e --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html @@ -0,0 +1,206 @@ +

+ + + +
+ + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
NameValue
{{key}}{{value}} + + +
+ No webhook headers have been added. +
+ + +
+ +
+ +
+
+ +
+ +
+ + + + + +
{{vm.webhook.id}}
+ {{vm.webhook.key}} +
+ +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + +
+ +
+ +
diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/logs.html b/src/Umbraco.Web.UI.Client/src/views/webhooks/logs.html index 6c13daf764..f2db3096af 100644 --- a/src/Umbraco.Web.UI.Client/src/views/webhooks/logs.html +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/logs.html @@ -22,7 +22,7 @@ {{ log.webhookKey }} {{ log.date }} {{ log.url }} - {{ log.eventName }} + {{ log.eventAlias }} {{ log.retryCount }} diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/details.html b/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/details.html index 3878c1ae87..d66c1463e7 100644 --- a/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/details.html +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/details.html @@ -22,7 +22,7 @@
Event -
{{model.log.eventName}}
+
{{model.log.eventAlias}}
Retry count diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.controller.js b/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.controller.js deleted file mode 100644 index 9df465f738..0000000000 --- a/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.controller.js +++ /dev/null @@ -1,134 +0,0 @@ -(function () { - "use strict"; - - function EditController($scope, editorService, contentTypeResource, mediaTypeResource) { - - const vm = this; - - vm.clearContentType = clearContentType; - vm.clearEvent = clearEvent; - vm.removeHeader = removeHeader; - vm.openCreateHeader = openCreateHeader; - vm.openEventPicker = openEventPicker; - vm.openContentTypePicker = openContentTypePicker; - vm.close = close; - vm.submit = submit; - - function openEventPicker() { - editorService.eventPicker({ - title: "Select event", - selectedEvents: $scope.model.webhook.events, - submit(model) { - $scope.model.webhook.events = model.selection; - editorService.close(); - }, - close() { - editorService.close(); - } - }); - } - - function openContentTypePicker() { - const isContent = $scope.model.webhook ? $scope.model.webhook.events[0].toLowerCase().includes("content") : null; - - const editor = { - multiPicker: true, - filterCssClass: "not-allowed not-published", - filter: function (item) { - // filter out folders (containers), element types (for content) and already selected items - return item.nodeType === "container"; // || item.metaData.isElement || !!_.findWhere(vm.itemTypes, { udi: item.udi }); - }, - submit(model) { - getEntities(model.selection, isContent); - $scope.model.webhook.contentTypeKeys = model.selection.map(item => item.key); - editorService.close(); - }, - close() { - editorService.close(); - } - }; - - const itemType = isContent ? "content" : "media"; - - switch (itemType) { - case "content": - editorService.contentTypePicker(editor); - break; - case "media": - editorService.mediaTypePicker(editor); - break; - case "member": - editorService.memberTypePicker(editor); - break; - } - } - - function openCreateHeader() { - editorService.open({ - title: "Create header", - view: "views/webhooks/overlays/header.html", - size: 'small', - position: 'right', - submit(model) { - if (!$scope.model.webhook.headers) { - $scope.model.webhook.headers = {}; - } - $scope.model.webhook.headers[model.key] = model.value; - editorService.close(); - }, - close() { - editorService.close(); - } - }); - } - - function getEntities(selection, isContent) { - const resource = isContent ? contentTypeResource : mediaTypeResource; - $scope.model.contentTypes = []; - - selection.forEach(entity => { - resource.getById(entity.key) - .then(data => { - $scope.model.contentTypes.push(data); - }); - }); - } - - function clearContentType(contentTypeKey) { - if (Utilities.isArray($scope.model.webhook.contentTypeKeys)) { - $scope.model.webhook.contentTypeKeys = $scope.model.webhook.contentTypeKeys.filter(x => x !== contentTypeKey); - } - if (Utilities.isArray($scope.model.contentTypes)) { - $scope.model.contentTypes = $scope.model.contentTypes.filter(x => x.key !== contentTypeKey); - } - } - - function clearEvent(event) { - if (Utilities.isArray($scope.model.webhook.events)) { - $scope.model.webhook.events = $scope.model.webhook.events.filter(x => x !== event); - } - - if (Utilities.isArray($scope.model.contentTypes)) { - $scope.model.events = $scope.model.events.filter(x => x.key !== event); - } - } - - function removeHeader(key) { - delete $scope.model.webhook.headers[key]; - } - - function close() { - if ($scope.model.close) { - $scope.model.close(); - } - } - - function submit() { - if ($scope.model.submit) { - $scope.model.submit($scope.model); - } - } - } - - angular.module("umbraco").controller("Umbraco.Editors.Webhooks.EditController", EditController); -})(); diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.html b/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.html deleted file mode 100644 index cf55320f2f..0000000000 --- a/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.html +++ /dev/null @@ -1,159 +0,0 @@ -
- - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameValue
{{key}}{{value}} - - -
- - - -
-
-
-
-
- - - - - - - - -
-
-
diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/webhooks.controller.js b/src/Umbraco.Web.UI.Client/src/views/webhooks/webhooks.controller.js index 94b1c21742..10a0f51c08 100644 --- a/src/Umbraco.Web.UI.Client/src/views/webhooks/webhooks.controller.js +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/webhooks.controller.js @@ -1,11 +1,12 @@ (function () { "use strict"; - function WebhookController($q, $timeout, $routeParams, webhooksResource, navigationService, notificationsService, editorService, overlayService, contentTypeResource, mediaTypeResource) { + function WebhookController($q, $timeout, $location, $routeParams, webhooksResource, navigationService, notificationsService, editorService, overlayService, contentTypeResource, mediaTypeResource, memberTypeResource) { const vm = this; - vm.openWebhookOverlay = openWebhookOverlay; + vm.addWebhook = addWebhook; + vm.editWebhook = editWebhook; vm.deleteWebhook = deleteWebhook; vm.handleSubmissionError = handleSubmissionError; vm.resolveTypeNames = resolveTypeNames; @@ -14,7 +15,7 @@ vm.page = {}; vm.webhooks = []; vm.events = []; - vm.webHooksContentTypes = {}; + vm.webhooksContentTypes = {}; vm.webhookEvents = {}; function init() { @@ -38,128 +39,81 @@ function loadEvents() { return webhooksResource.getAllEvents() .then(data => { - vm.events = data.map(item => item.eventName); + vm.events = data; }); } function resolveEventNames(webhook) { webhook.events.forEach(event => { if (!vm.webhookEvents[webhook.key]) { - vm.webhookEvents[webhook.key] = event; + vm.webhookEvents[webhook.key] = event.eventName; } else { - vm.webhookEvents[webhook.key] += ", " + event; + vm.webhookEvents[webhook.key] += ", " + event.eventName; } }); } - function getEntities(webhook) { - const isContent = webhook.events[0].toLowerCase().includes("content"); - const resource = isContent ? contentTypeResource : mediaTypeResource; - let entities = []; + function determineResource(resourceType){ + let resource; + switch (resourceType) { + case "content": + resource = contentTypeResource; + break; + case "media": + resource = mediaTypeResource; + break; + case "member": + resource = memberTypeResource; + break; + default: + return; + } - webhook.contentTypeKeys.forEach(key => { - resource.getById(key) - .then(data => { - entities.push(data); - }); - }); - - return entities; + return resource; } function resolveTypeNames(webhook) { - const isContent = webhook.events[0].toLowerCase().includes("content"); - const resource = isContent ? contentTypeResource : mediaTypeResource; + let resource = determineResource(webhook.events[0].eventType.toLowerCase()); - if (vm.webHooksContentTypes[webhook.key]){ - delete vm.webHooksContentTypes[webhook.key]; + if (vm.webhooksContentTypes[webhook.key]){ + delete vm.webhooksContentTypes[webhook.key]; } webhook.contentTypeKeys.forEach(key => { resource.getById(key) .then(data => { - if (!vm.webHooksContentTypes[webhook.key]) { - vm.webHooksContentTypes[webhook.key] = data.name; + if (!vm.webhooksContentTypes[webhook.key]) { + vm.webhooksContentTypes[webhook.key] = data.name; } else { - vm.webHooksContentTypes[webhook.key] += ", " + data.name; + vm.webhooksContentTypes[webhook.key] += ", " + data.name; } }); }); } - function handleSubmissionError (model, errorMessage) { + function handleSubmissionError(model, errorMessage) { notificationsService.error(errorMessage); model.disableSubmitButton = false; model.submitButtonState = 'error'; } - function openWebhookOverlay (webhook) { - let isCreating = !webhook; - editorService.open({ - title: webhook ? 'Edit webhook' : 'Add webhook', - position: 'right', - size: 'small', - submitButtonLabel: webhook ? 'Save' : 'Create', - view: "views/webhooks/overlays/edit.html", - events: vm.events, - contentTypes : webhook ? getEntities(webhook) : null, - webhook: webhook ? webhook : {enabled: true}, - submit: (model) => { - model.disableSubmitButton = true; - model.submitButtonState = 'busy'; - if (!model.webhook.url) { - //Due to validation url will only be populated if it's valid, hence we can make do with checking url is there - handleSubmissionError(model, 'Please provide a valid URL. Did you include https:// ?'); - return; - } - if (!model.webhook.events || model.webhook.events.length === 0) { - handleSubmissionError(model, 'Please provide the event for which the webhook should trigger'); - return; - } + function addWebhook() { + $location.search('create', null); + $location.path("/settings/webhooks/edit/-1").search("create", "true"); + } - if (isCreating) { - webhooksResource.create(model.webhook) - .then(() => { - loadWebhooks() - notificationsService.success('Webhook saved.'); - editorService.close(); - }, x => { - let errorMessage = undefined; - if (x.data.ModelState) { - errorMessage = `Message: ${Object.values(x.data.ModelState).flat().join(' ')}`; - } - handleSubmissionError(model, `Error saving webhook. ${errorMessage ?? ''}`); - }); - } - else { - webhooksResource.update(model.webhook) - .then(() => { - loadWebhooks() - notificationsService.success('Webhook saved.'); - editorService.close(); - }, x => { - let errorMessage = undefined; - if (x.data.ModelState) { - errorMessage = `Message: ${Object.values(x.data.ModelState).flat().join(' ')}`; - } - handleSubmissionError(model, `Error saving webhook. ${errorMessage ?? ''}`); - }); - } - - }, - close: () => { - editorService.close(); - } - }); + function editWebhook(webhook) { + $location.search('create', null); + $location.path("/settings/webhooks/edit/" + webhook.key); } function loadWebhooks(){ - webhooksResource + return webhooksResource .getAll() .then(result => { vm.webhooks = result; vm.webhookEvents = {}; - vm.webHooksContentTypes = {}; + vm.webhooksContentTypes = {}; vm.webhooks.forEach(webhook => { resolveTypeNames(webhook); @@ -168,7 +122,7 @@ }); } - function deleteWebhook (webhook, event) { + function deleteWebhook(webhook, event) { overlayService.open({ title: 'Confirm delete webhook', content: 'Are you sure you want to delete the webhook?', diff --git a/src/Umbraco.Web.UI.Client/src/views/webhooks/webhooks.html b/src/Umbraco.Web.UI.Client/src/views/webhooks/webhooks.html index e6f4e8f53d..70d2a0be05 100644 --- a/src/Umbraco.Web.UI.Client/src/views/webhooks/webhooks.html +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/webhooks.html @@ -6,7 +6,7 @@ @@ -24,7 +24,7 @@ - + {{ vm.webhookEvents[webhook.key] }} {{ webhook.url }} - {{ vm.webHooksContentTypes[webhook.key] }} + {{ vm.webhooksContentTypes[webhook.key] }} { @customElement('umb-auth') export default class UmbAuthElement extends LitElement { - #returnPath = ''; - /** * Disables the local login form and only allows external login providers. * @@ -89,12 +87,7 @@ export default class UmbAuthElement extends LitElement { @property({ type: String, attribute: 'return-url' }) set returnPath(value: string) { - this.#returnPath = value; - umbAuthContext.returnPath = this.returnPath; - } - get returnPath() { - // Check if there is a ?redir querystring or else return the returnUrl attribute - return new URLSearchParams(window.location.search).get('returnPath') || this.#returnPath; + umbAuthContext.returnPath = value; } /** diff --git a/src/Umbraco.Web.UI.Login/src/components/pages/mfa.page.element.ts b/src/Umbraco.Web.UI.Login/src/components/pages/mfa.page.element.ts index c7d1efb804..8369c333a1 100644 --- a/src/Umbraco.Web.UI.Login/src/components/pages/mfa.page.element.ts +++ b/src/Umbraco.Web.UI.Login/src/components/pages/mfa.page.element.ts @@ -126,7 +126,7 @@ export default class UmbMfaPageElement extends LitElement {

- One last step + One last step

diff --git a/src/Umbraco.Web.UI.Login/src/context/auth.context.ts b/src/Umbraco.Web.UI.Login/src/context/auth.context.ts index 4abd9441a3..b84ecfd039 100644 --- a/src/Umbraco.Web.UI.Login/src/context/auth.context.ts +++ b/src/Umbraco.Web.UI.Login/src/context/auth.context.ts @@ -15,7 +15,30 @@ export class UmbAuthContext implements IUmbAuthContext { #authRepository = new UmbAuthRepository(); - public returnPath = ''; + #returnPath = ''; + + set returnPath(value: string) { + this.#returnPath = value; + } + + /** + * Gets the return path from the query string. + * + * It will first look for a `ReturnUrl` parameter, then a `returnPath` parameter, and finally the `returnPath` property. + * + * @returns The return path from the query string. + */ + get returnPath(): string { + const params = new URLSearchParams(window.location.search); + let returnUrl = params.get('ReturnUrl') ?? params.get('returnPath') ?? this.#returnPath; + + // Paths from the old Backoffice are encoded twice and need to be decoded, + // but we don't want to decode the new paths coming from the Management API. + if (returnUrl.indexOf('/security/back-office/authorize') === -1) { + returnUrl = decodeURIComponent(returnUrl); + } + return returnUrl || ''; + } async login(data: LoginRequestModel): Promise { return this.#authRepository.login(data); diff --git a/src/Umbraco.Web.UI.Login/src/context/auth.repository.ts b/src/Umbraco.Web.UI.Login/src/context/auth.repository.ts index 46e0ca4215..24c1cfe015 100644 --- a/src/Umbraco.Web.UI.Login/src/context/auth.repository.ts +++ b/src/Umbraco.Web.UI.Login/src/context/auth.repository.ts @@ -10,254 +10,266 @@ import { umbLocalizationContext } from '../external/localization/localization-co export class UmbAuthRepository { readonly #authURL = 'backoffice/umbracoapi/authentication/postlogin'; - public async login(data: LoginRequestModel): Promise { - try { - const request = new Request(this.#authURL, { - method: 'POST', - body: JSON.stringify({ - username: data.username, - password: data.password, - rememberMe: data.persist, - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - const response = await fetch(request); + public async login(data: LoginRequestModel): Promise { + try { + const request = new Request(this.#authURL, { + method: 'POST', + body: JSON.stringify({ + username: data.username, + password: data.password, + rememberMe: data.persist, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + const response = await fetch(request); - const text = await response.text(); - const responseData = JSON.parse(this.#removeAngularJSResponseData(text)); + const responseData: LoginResponse = { + status: response.status + }; - return { - status: response.status, - error: response.ok ? undefined : await this.#getErrorText(response), - data: responseData, - twoFactorView: responseData?.twoFactorView, - }; - } catch (error) { - return { - status: 500, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } + if (!response.ok) { + responseData.error = await this.#getErrorText(response); + return responseData; + } - public async resetPassword(email: string): Promise { - const request = new Request('backoffice/umbracoapi/authentication/PostRequestPasswordReset', { - method: 'POST', - body: JSON.stringify({ - email, - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - const response = await fetch(request); + try { + const text = await response.text(); + if (text) { + responseData.data = JSON.parse(this.#removeAngularJSResponseData(text)); + } + } catch {} - return { - status: response.status, - error: response.ok ? undefined : await this.#getErrorText(response), - }; - } + return { + status: response.status, + data: responseData?.data, + twoFactorView: responseData?.twoFactorView, + }; + } catch (error) { + return { + status: 500, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } - public async validatePasswordResetCode(user: string, code: string): Promise { - const request = new Request('backoffice/umbracoapi/authentication/validatepasswordresetcode', { - method: 'POST', - body: JSON.stringify({ - userId: user, - resetCode: code, - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - const response = await fetch(request); + public async resetPassword(email: string): Promise { + const request = new Request('backoffice/umbracoapi/authentication/PostRequestPasswordReset', { + method: 'POST', + body: JSON.stringify({ + email, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + const response = await fetch(request); - return { - status: response.status, - error: response.ok ? undefined : await this.#getErrorText(response), - }; - } + return { + status: response.status, + error: response.ok ? undefined : await this.#getErrorText(response), + }; + } - public async newPassword(password: string, resetCode: string, userId: number): Promise { - const request = new Request('backoffice/umbracoapi/authentication/PostSetPassword', { - method: 'POST', - body: JSON.stringify({ - password, - resetCode, - userId, - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - const response = await fetch(request); + public async validatePasswordResetCode(user: string, code: string): Promise { + const request = new Request('backoffice/umbracoapi/authentication/validatepasswordresetcode', { + method: 'POST', + body: JSON.stringify({ + userId: user, + resetCode: code, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + const response = await fetch(request); - return { - status: response.status, - error: response.ok ? undefined : await this.#getErrorText(response), - }; - } + return { + status: response.status, + error: response.ok ? undefined : await this.#getErrorText(response), + }; + } - public async newInvitedUserPassword(newPassWord: string): Promise { - const request = new Request('backoffice/umbracoapi/authentication/PostSetInvitedUserPassword', { - method: 'POST', - body: JSON.stringify({ - newPassWord, - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - const response = await fetch(request); + public async newPassword(password: string, resetCode: string, userId: number): Promise { + const request = new Request('backoffice/umbracoapi/authentication/PostSetPassword', { + method: 'POST', + body: JSON.stringify({ + password, + resetCode, + userId, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + const response = await fetch(request); - return { - status: response.status, - error: response.ok ? undefined : await this.#getErrorText(response), - }; - } + return { + status: response.status, + error: response.ok ? undefined : await this.#getErrorText(response), + }; + } - public async getPasswordConfig(userId: string): Promise { - //TODO: Add type - const request = new Request(`backoffice/umbracoapi/authentication/GetPasswordConfig?userId=${userId}`, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - const response = await fetch(request); + public async newInvitedUserPassword(newPassWord: string): Promise { + const request = new Request('backoffice/umbracoapi/authentication/PostSetInvitedUserPassword', { + method: 'POST', + body: JSON.stringify({ + newPassWord, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + const response = await fetch(request); - // Check if response contains AngularJS response data - if (response.ok) { - let text = await response.text(); - text = this.#removeAngularJSResponseData(text); - const data = JSON.parse(text); + return { + status: response.status, + error: response.ok ? undefined : await this.#getErrorText(response), + }; + } - return { - status: response.status, - data, - }; - } + public async getPasswordConfig(userId: string): Promise { + //TODO: Add type + const request = new Request(`backoffice/umbracoapi/authentication/GetPasswordConfig?userId=${userId}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + const response = await fetch(request); - return { - status: response.status, - error: response.ok ? undefined : this.#getErrorText(response), - }; - } + // Check if response contains AngularJS response data + if (response.ok) { + let text = await response.text(); + text = this.#removeAngularJSResponseData(text); + const data = JSON.parse(text); - public async getInvitedUser(): Promise { - //TODO: Add type - const request = new Request('backoffice/umbracoapi/authentication/GetCurrentInvitedUser', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - const response = await fetch(request); + return { + status: response.status, + data, + }; + } - // Check if response contains AngularJS response data - if (response.ok) { - let text = await response.text(); - text = this.#removeAngularJSResponseData(text); - const user = JSON.parse(text); + return { + status: response.status, + error: response.ok ? undefined : this.#getErrorText(response), + }; + } - return { - status: response.status, - user, - }; - } + public async getInvitedUser(): Promise { + //TODO: Add type + const request = new Request('backoffice/umbracoapi/authentication/GetCurrentInvitedUser', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + const response = await fetch(request); - return { - status: response.status, - error: this.#getErrorText(response), - }; - } + // Check if response contains AngularJS response data + if (response.ok) { + let text = await response.text(); + text = this.#removeAngularJSResponseData(text); + const user = JSON.parse(text); - public async getMfaProviders(): Promise { - const request = new Request('backoffice/umbracoapi/authentication/Get2faProviders', { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }); - const response = await fetch(request); + return { + status: response.status, + user, + }; + } - // Check if response contains AngularJS response data - if (response.ok) { - let text = await response.text(); - text = this.#removeAngularJSResponseData(text); - const providers = JSON.parse(text); + return { + status: response.status, + error: this.#getErrorText(response), + }; + } - return { - status: response.status, - providers, - }; - } + public async getMfaProviders(): Promise { + const request = new Request('backoffice/umbracoapi/authentication/Get2faProviders', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + const response = await fetch(request); - return { - status: response.status, - error: await this.#getErrorText(response), - providers: [], - }; - } + // Check if response contains AngularJS response data + if (response.ok) { + let text = await response.text(); + text = this.#removeAngularJSResponseData(text); + const providers = JSON.parse(text); - public async validateMfaCode(code: string, provider: string): Promise { - const request = new Request('backoffice/umbracoapi/authentication/PostVerify2faCode', { - method: 'POST', - body: JSON.stringify({ - code, - provider, - }), - headers: { - 'Content-Type': 'application/json', - }, - }); + return { + status: response.status, + providers, + }; + } - const response = await fetch(request); + return { + status: response.status, + error: await this.#getErrorText(response), + providers: [], + }; + } + + public async validateMfaCode(code: string, provider: string): Promise { + const request = new Request('backoffice/umbracoapi/authentication/PostVerify2faCode', { + method: 'POST', + body: JSON.stringify({ + code, + provider, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + + const response = await fetch(request); let text = await response.text(); text = this.#removeAngularJSResponseData(text); const data = JSON.parse(text); - if (response.ok) { - return { + if (response.ok) { + return { data, - status: response.status, - }; - } + status: response.status, + }; + } - return { - status: response.status, - error: data.Message ?? 'An unknown error occurred.', - }; - } + return { + status: response.status, + error: data.Message ?? 'An unknown error occurred.', + }; + } - async #getErrorText(response: Response): Promise { - switch (response.status) { - case 400: - case 401: - return umbLocalizationContext.localize('login_userFailedLogin', undefined, "Oops! We couldn't log you in. Please check your credentials and try again."); + async #getErrorText(response: Response): Promise { + switch (response.status) { + case 400: + case 401: + return umbLocalizationContext.localize('login_userFailedLogin', undefined, "Oops! We couldn't log you in. Please check your credentials and try again."); - case 402: - return umbLocalizationContext.localize('login_2faText', undefined, 'You have enabled 2-factor authentication and must verify your identity.'); + case 402: + return umbLocalizationContext.localize('login_2faText', undefined, 'You have enabled 2-factor authentication and must verify your identity.'); - case 500: - return umbLocalizationContext.localize('errors_receivedErrorFromServer', undefined, 'Received error from server'); + case 500: + return umbLocalizationContext.localize('errors_receivedErrorFromServer', undefined, 'Received error from server'); - default: - return response.statusText ?? await umbLocalizationContext.localize('errors_receivedErrorFromServer', undefined, 'Received error from server') - } - } + default: + return response.statusText ?? await umbLocalizationContext.localize('errors_receivedErrorFromServer', undefined, 'Received error from server') + } + } - /** - * AngularJS adds a prefix to the response data, which we need to remove - */ - #removeAngularJSResponseData(text: string) { - if (text.startsWith(")]}',\n")) { - text = text.split('\n')[1]; - } + /** + * AngularJS adds a prefix to the response data, which we need to remove + */ + #removeAngularJSResponseData(text: string) { + if (text.startsWith(")]}',\n")) { + text = text.split('\n')[1]; + } - return text; - } + return text; + } } diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index 695f25b4fe..cbcba73da7 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -17,7 +17,7 @@ - + diff --git a/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj b/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj index c84c8fad6e..8dd0bd4060 100644 --- a/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj +++ b/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj @@ -13,7 +13,7 @@ - + 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.Integration/Umbraco.Core/Services/WebhookLogServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookLogServiceTests.cs index af75becd1c..626e1f8b59 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookLogServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookLogServiceTests.cs @@ -21,7 +21,7 @@ public class WebhookLogServiceTests : UmbracoIntegrationTest var createdWebhookLog = await WebhookLogService.CreateAsync(new WebhookLog { Date = DateTime.UtcNow, - EventName = Constants.WebhookEvents.ContentPublish, + EventAlias = Constants.WebhookEvents.Aliases.ContentPublish, RequestBody = "Test Request Body", ResponseBody = "Test response body", StatusCode = "200", @@ -37,14 +37,14 @@ public class WebhookLogServiceTests : UmbracoIntegrationTest Assert.IsNotNull(webhookLogsPaged); Assert.IsNotEmpty(webhookLogsPaged.Items); Assert.AreEqual(1, webhookLogsPaged.Items.Count()); - var webHookLog = webhookLogsPaged.Items.First(); - Assert.AreEqual(createdWebhookLog.Date.ToString(CultureInfo.InvariantCulture), webHookLog.Date.ToString(CultureInfo.InvariantCulture)); - Assert.AreEqual(createdWebhookLog.EventName, webHookLog.EventName); - Assert.AreEqual(createdWebhookLog.RequestBody, webHookLog.RequestBody); - Assert.AreEqual(createdWebhookLog.ResponseBody, webHookLog.ResponseBody); - Assert.AreEqual(createdWebhookLog.StatusCode, webHookLog.StatusCode); - Assert.AreEqual(createdWebhookLog.RetryCount, webHookLog.RetryCount); - Assert.AreEqual(createdWebhookLog.Key, webHookLog.Key); + var webhookLog = webhookLogsPaged.Items.First(); + Assert.AreEqual(createdWebhookLog.Date.ToString(CultureInfo.InvariantCulture), webhookLog.Date.ToString(CultureInfo.InvariantCulture)); + Assert.AreEqual(createdWebhookLog.EventAlias, webhookLog.EventAlias); + Assert.AreEqual(createdWebhookLog.RequestBody, webhookLog.RequestBody); + Assert.AreEqual(createdWebhookLog.ResponseBody, webhookLog.ResponseBody); + Assert.AreEqual(createdWebhookLog.StatusCode, webhookLog.StatusCode); + Assert.AreEqual(createdWebhookLog.RetryCount, webhookLog.RetryCount); + Assert.AreEqual(createdWebhookLog.Key, webhookLog.Key); }); } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookRequestServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookRequestServiceTests.cs new file mode 100644 index 0000000000..a4e2ded5df --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookRequestServiceTests.cs @@ -0,0 +1,67 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public class WebhookRequestServiceTests : UmbracoIntegrationTest +{ + private IWebhookRequestService WebhookRequestService => GetRequiredService(); + + private IWebhookService WebhookService => GetRequiredService(); + + [Test] + public async Task Can_Create_And_Get() + { + var createdWebhook = await WebhookService.CreateAsync(new Webhook("https://example.com", true, new[] { Guid.NewGuid() }, new[] { Constants.WebhookEvents.Aliases.ContentPublish })); + var created = await WebhookRequestService.CreateAsync(createdWebhook.Result.Key, Constants.WebhookEvents.Aliases.ContentPublish, null); + var webhooks = await WebhookRequestService.GetAllAsync(); + var webhook = webhooks.First(x => x.Id == created.Id); + + Assert.Multiple(() => + { + Assert.AreEqual(created.Id, webhook.Id); + Assert.AreEqual(created.EventAlias, webhook.EventAlias); + Assert.AreEqual(created.RetryCount, webhook.RetryCount); + Assert.AreEqual(created.RequestObject, webhook.RequestObject); + Assert.AreEqual(created.WebhookKey, webhook.WebhookKey); + }); + } + + [Test] + public async Task Can_Update() + { + var newRetryCount = 4; + var createdWebhook = await WebhookService.CreateAsync(new Webhook("https://example.com", true, new[] { Guid.NewGuid() }, new[] { Constants.WebhookEvents.Aliases.ContentPublish })); + var created = await WebhookRequestService.CreateAsync(createdWebhook.Result.Key, Constants.WebhookEvents.Aliases.ContentPublish, null); + created.RetryCount = newRetryCount; + await WebhookRequestService.UpdateAsync(created); + var webhooks = await WebhookRequestService.GetAllAsync(); + var webhook = webhooks.First(x => x.Id == created.Id); + + Assert.Multiple(() => + { + Assert.AreEqual(newRetryCount, webhook.RetryCount); + }); + } + + [Test] + public async Task Can_Delete() + { + var createdWebhook = await WebhookService.CreateAsync(new Webhook("https://example.com", true, new[] { Guid.NewGuid() }, new[] { Constants.WebhookEvents.Aliases.ContentPublish })); + var created = await WebhookRequestService.CreateAsync(createdWebhook.Result.Key, Constants.WebhookEvents.Aliases.ContentPublish, null); + await WebhookRequestService.DeleteAsync(created); + var webhooks = await WebhookRequestService.GetAllAsync(); + var webhook = webhooks.FirstOrDefault(x => x.Id == created.Id); + + Assert.Multiple(() => + { + Assert.IsNull(webhook); + }); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookServiceTests.cs index 6f6da74485..53368593c1 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookServiceTests.cs @@ -11,18 +11,18 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services; [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] public class WebhookServiceTests : UmbracoIntegrationTest { - private IWebHookService WebhookService => GetRequiredService(); + private IWebhookService WebhookService => GetRequiredService(); [Test] - [TestCase("https://example.com", Constants.WebhookEvents.ContentPublish, "00000000-0000-0000-0000-010000000000")] - [TestCase("https://example.com", Constants.WebhookEvents.ContentDelete, "00000000-0000-0000-0000-000200000000")] - [TestCase("https://example.com", Constants.WebhookEvents.ContentUnpublish, "00000000-0000-0000-0000-300000000000")] - [TestCase("https://example.com", Constants.WebhookEvents.MediaDelete, "00000000-0000-0000-0000-000004000000")] - [TestCase("https://example.com", Constants.WebhookEvents.MediaSave, "00000000-0000-0000-0000-000000500000")] + [TestCase("https://example.com", Constants.WebhookEvents.Aliases.ContentPublish, "00000000-0000-0000-0000-010000000000")] + [TestCase("https://example.com", Constants.WebhookEvents.Aliases.ContentDelete, "00000000-0000-0000-0000-000200000000")] + [TestCase("https://example.com", Constants.WebhookEvents.Aliases.ContentUnpublish, "00000000-0000-0000-0000-300000000000")] + [TestCase("https://example.com", Constants.WebhookEvents.Aliases.MediaDelete, "00000000-0000-0000-0000-000004000000")] + [TestCase("https://example.com", Constants.WebhookEvents.Aliases.MediaSave, "00000000-0000-0000-0000-000000500000")] public async Task Can_Create_And_Get(string url, string webhookEvent, Guid key) { var createdWebhook = await WebhookService.CreateAsync(new Webhook(url, true, new[] { key }, new[] { webhookEvent })); - var webhook = await WebhookService.GetAsync(createdWebhook.Key); + var webhook = await WebhookService.GetAsync(createdWebhook.Result.Key); Assert.Multiple(() => { @@ -37,42 +37,42 @@ public class WebhookServiceTests : UmbracoIntegrationTest [Test] public async Task Can_Get_All() { - var createdWebhookOne = await WebhookService.CreateAsync(new Webhook("https://example.com", true, new[] { Guid.NewGuid() }, new[] { Constants.WebhookEvents.ContentPublish })); - var createdWebhookTwo = await WebhookService.CreateAsync(new Webhook("https://example.com", true, new[] { Guid.NewGuid() }, new[] { Constants.WebhookEvents.ContentDelete })); - var createdWebhookThree = await WebhookService.CreateAsync(new Webhook("https://example.com", true, new[] { Guid.NewGuid() }, new[] { Constants.WebhookEvents.ContentUnpublish })); + var createdWebhookOne = await WebhookService.CreateAsync(new Webhook("https://example.com", true, new[] { Guid.NewGuid() }, new[] { Constants.WebhookEvents.Aliases.ContentPublish })); + var createdWebhookTwo = await WebhookService.CreateAsync(new Webhook("https://example.com", true, new[] { Guid.NewGuid() }, new[] { Constants.WebhookEvents.Aliases.ContentDelete })); + var createdWebhookThree = await WebhookService.CreateAsync(new Webhook("https://example.com", true, new[] { Guid.NewGuid() }, new[] { Constants.WebhookEvents.Aliases.ContentUnpublish })); var webhooks = await WebhookService.GetAllAsync(0, int.MaxValue); Assert.Multiple(() => { Assert.IsNotEmpty(webhooks.Items); - Assert.IsNotNull(webhooks.Items.FirstOrDefault(x => x.Key == createdWebhookOne.Key)); - Assert.IsNotNull(webhooks.Items.FirstOrDefault(x => x.Key == createdWebhookTwo.Key)); - Assert.IsNotNull(webhooks.Items.FirstOrDefault(x => x.Key == createdWebhookThree.Key)); + Assert.IsNotNull(webhooks.Items.FirstOrDefault(x => x.Key == createdWebhookOne.Result.Key)); + Assert.IsNotNull(webhooks.Items.FirstOrDefault(x => x.Key == createdWebhookTwo.Result.Key)); + Assert.IsNotNull(webhooks.Items.FirstOrDefault(x => x.Key == createdWebhookThree.Result.Key)); }); } [Test] - [TestCase("https://example.com", Constants.WebhookEvents.ContentPublish, "00000000-0000-0000-0000-010000000000")] - [TestCase("https://example.com", Constants.WebhookEvents.ContentDelete, "00000000-0000-0000-0000-000200000000")] - [TestCase("https://example.com", Constants.WebhookEvents.ContentUnpublish, "00000000-0000-0000-0000-300000000000")] - [TestCase("https://example.com", Constants.WebhookEvents.MediaDelete, "00000000-0000-0000-0000-000004000000")] - [TestCase("https://example.com", Constants.WebhookEvents.MediaSave, "00000000-0000-0000-0000-000000500000")] + [TestCase("https://example.com", Constants.WebhookEvents.Aliases.ContentPublish, "00000000-0000-0000-0000-010000000000")] + [TestCase("https://example.com", Constants.WebhookEvents.Aliases.ContentDelete, "00000000-0000-0000-0000-000200000000")] + [TestCase("https://example.com", Constants.WebhookEvents.Aliases.ContentUnpublish, "00000000-0000-0000-0000-300000000000")] + [TestCase("https://example.com", Constants.WebhookEvents.Aliases.MediaDelete, "00000000-0000-0000-0000-000004000000")] + [TestCase("https://example.com", Constants.WebhookEvents.Aliases.MediaSave, "00000000-0000-0000-0000-000000500000")] public async Task Can_Delete(string url, string webhookEvent, Guid key) { var createdWebhook = await WebhookService.CreateAsync(new Webhook(url, true, new[] { key }, new[] { webhookEvent })); - var webhook = await WebhookService.GetAsync(createdWebhook.Key); + var webhook = await WebhookService.GetAsync(createdWebhook.Result.Key); Assert.IsNotNull(webhook); await WebhookService.DeleteAsync(webhook.Key); - var deletedWebhook = await WebhookService.GetAsync(createdWebhook.Key); + var deletedWebhook = await WebhookService.GetAsync(createdWebhook.Result.Key); Assert.IsNull(deletedWebhook); } [Test] public async Task Can_Create_With_No_EntityKeys() { - var createdWebhook = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.ContentPublish })); - var webhook = await WebhookService.GetAsync(createdWebhook.Key); + var createdWebhook = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.Aliases.ContentPublish })); + var webhook = await WebhookService.GetAsync(createdWebhook.Result.Key); Assert.IsNotNull(webhook); Assert.IsEmpty(webhook.ContentTypeKeys); @@ -81,24 +81,24 @@ public class WebhookServiceTests : UmbracoIntegrationTest [Test] public async Task Can_Update() { - var createdWebhook = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.ContentPublish })); - createdWebhook.Events = new[] { Constants.WebhookEvents.ContentDelete }; - await WebhookService.UpdateAsync(createdWebhook); + var createdWebhook = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.Aliases.ContentPublish })); + createdWebhook.Result.Events = new[] { Constants.WebhookEvents.Aliases.ContentDelete }; + await WebhookService.UpdateAsync(createdWebhook.Result); - var updatedWebhook = await WebhookService.GetAsync(createdWebhook.Key); + var updatedWebhook = await WebhookService.GetAsync(createdWebhook.Result.Key); Assert.IsNotNull(updatedWebhook); Assert.AreEqual(1, updatedWebhook.Events.Length); - Assert.IsTrue(updatedWebhook.Events.Contains(Constants.WebhookEvents.ContentDelete)); + Assert.IsTrue(updatedWebhook.Events.Contains(Constants.WebhookEvents.Aliases.ContentDelete)); } [Test] public async Task Can_Get_By_EventName() { - var webhook1 = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.ContentPublish })); - var webhook2 = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.ContentUnpublish })); - var webhook3 = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.ContentUnpublish })); + var webhook1 = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.Aliases.ContentPublish })); + var webhook2 = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.Aliases.ContentUnpublish })); + var webhook3 = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.Aliases.ContentUnpublish })); - var result = await WebhookService.GetByEventNameAsync(Constants.WebhookEvents.ContentUnpublish); + var result = await WebhookService.GetByAliasAsync(Constants.WebhookEvents.Aliases.ContentUnpublish); Assert.IsNotEmpty(result); Assert.AreEqual(2, result.Count()); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index be3eda9c2f..22661ce535 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -12,7 +12,7 @@ - + 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; +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJobTests.cs new file mode 100644 index 0000000000..cf9883603b --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJobTests.cs @@ -0,0 +1,139 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.HealthChecks; +using Umbraco.Cms.Core.HealthChecks.NotificationMethods; +using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.BackgroundJobs; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +using Umbraco.Cms.Infrastructure.Scoping; +using Umbraco.Cms.Tests.Common; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs; + +[TestFixture] +public class HealthCheckNotifierJobTests +{ + private Mock _mockNotificationMethod; + + private const string Check1Id = "00000000-0000-0000-0000-000000000001"; + private const string Check2Id = "00000000-0000-0000-0000-000000000002"; + private const string Check3Id = "00000000-0000-0000-0000-000000000003"; + + [Test] + public async Task Does_Not_Execute_When_Not_Enabled() + { + var sut = CreateHealthCheckNotifier(false); + await sut.RunJobAsync(); + VerifyNotificationsNotSent(); + } + + [Test] + public async Task Does_Not_Execute_With_No_Enabled_Notification_Methods() + { + var sut = CreateHealthCheckNotifier(notificationEnabled: false); + await sut.RunJobAsync(); + VerifyNotificationsNotSent(); + } + + [Test] + public async Task Executes_With_Enabled_Notification_Methods() + { + var sut = CreateHealthCheckNotifier(); + await sut.RunJobAsync(); + VerifyNotificationsSent(); + } + + [Test] + public async Task Executes_Only_Enabled_Checks() + { + var sut = CreateHealthCheckNotifier(); + await sut.RunJobAsync(); + _mockNotificationMethod.Verify( + x => x.SendAsync( + It.Is(y => + y.ResultsAsDictionary.Count == 1 && y.ResultsAsDictionary.ContainsKey("Check1"))), + Times.Once); + } + + private HealthCheckNotifierJob CreateHealthCheckNotifier( + bool enabled = true, + bool notificationEnabled = true) + { + var settings = new HealthChecksSettings + { + Notification = new HealthChecksNotificationSettings + { + Enabled = enabled, + DisabledChecks = new List { new() { Id = Guid.Parse(Check3Id) } }, + }, + DisabledChecks = new List { new() { Id = Guid.Parse(Check2Id) } }, + }; + var checks = new HealthCheckCollection(() => new List + { + new TestHealthCheck1(), + new TestHealthCheck2(), + new TestHealthCheck3(), + }); + + _mockNotificationMethod = new Mock(); + _mockNotificationMethod.SetupGet(x => x.Enabled).Returns(notificationEnabled); + var notifications = new HealthCheckNotificationMethodCollection(() => + new List { _mockNotificationMethod.Object }); + + + var mockScopeProvider = new Mock(); + var mockLogger = new Mock>(); + var mockProfilingLogger = new Mock(); + + return new HealthCheckNotifierJob( + new TestOptionsMonitor(settings), + checks, + notifications, + mockScopeProvider.Object, + mockLogger.Object, + mockProfilingLogger.Object, + Mock.Of()); + } + + private void VerifyNotificationsNotSent() => VerifyNotificationsSentTimes(Times.Never()); + + private void VerifyNotificationsSent() => VerifyNotificationsSentTimes(Times.Once()); + + private void VerifyNotificationsSentTimes(Times times) => + _mockNotificationMethod.Verify(x => x.SendAsync(It.IsAny()), times); + + [HealthCheck(Check1Id, "Check1")] + private class TestHealthCheck1 : TestHealthCheck + { + } + + [HealthCheck(Check2Id, "Check2")] + private class TestHealthCheck2 : TestHealthCheck + { + } + + [HealthCheck(Check3Id, "Check3")] + private class TestHealthCheck3 : TestHealthCheck + { + } + + private class TestHealthCheck : HealthCheck + { + public override HealthCheckStatus ExecuteAction(HealthCheckAction action) => new("Check message"); + + public override async Task> GetStatus() => Enumerable.Empty(); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/KeepAliveJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/KeepAliveJobTests.cs new file mode 100644 index 0000000000..6821cbcccc --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/KeepAliveJobTests.cs @@ -0,0 +1,94 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using Moq.Protected; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +using Umbraco.Cms.Infrastructure.HostedServices; +using Umbraco.Cms.Tests.Common; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs; + +[TestFixture] +public class KeepAliveJobTests +{ + private Mock _mockHttpMessageHandler; + + private const string ApplicationUrl = "https://mysite.com"; + + [Test] + public async Task Does_Not_Execute_When_Not_Enabled() + { + var sut = CreateKeepAlive(false); + await sut.RunJobAsync(); + VerifyKeepAliveRequestNotSent(); + } + + + [Test] + public async Task Executes_And_Calls_Ping_Url() + { + var sut = CreateKeepAlive(); + await sut.RunJobAsync(); + VerifyKeepAliveRequestSent(); + } + + private KeepAliveJob CreateKeepAlive( + bool enabled = true) + { + var settings = new KeepAliveSettings { DisableKeepAliveTask = !enabled }; + + var mockHostingEnvironment = new Mock(); + mockHostingEnvironment.SetupGet(x => x.ApplicationMainUrl).Returns(new Uri(ApplicationUrl)); + mockHostingEnvironment.Setup(x => x.ToAbsolute(It.IsAny())) + .Returns((string s) => s.TrimStart('~')); + + var mockScopeProvider = new Mock(); + var mockLogger = new Mock>(); + var mockProfilingLogger = new Mock(); + + _mockHttpMessageHandler = new Mock(); + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)) + .Verifiable(); + _mockHttpMessageHandler.As().Setup(s => s.Dispose()); + var httpClient = new HttpClient(_mockHttpMessageHandler.Object); + + var mockHttpClientFactory = new Mock(MockBehavior.Strict); + mockHttpClientFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(httpClient); + + return new KeepAliveJob( + mockHostingEnvironment.Object, + new TestOptionsMonitor(settings), + mockLogger.Object, + mockProfilingLogger.Object, + mockHttpClientFactory.Object); + } + + private void VerifyKeepAliveRequestNotSent() => VerifyKeepAliveRequestSentTimes(Times.Never()); + + private void VerifyKeepAliveRequestSent() => VerifyKeepAliveRequestSentTimes(Times.Once()); + + private void VerifyKeepAliveRequestSentTimes(Times times) => _mockHttpMessageHandler.Protected() + .Verify( + "SendAsync", + times, + ItExpr.Is(x => x.RequestUri.ToString() == $"{ApplicationUrl}/api/keepalive/ping"), + ItExpr.IsAny()); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJobTests.cs new file mode 100644 index 0000000000..6dd479364c --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJobTests.cs @@ -0,0 +1,72 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Data; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Logging; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +using Umbraco.Cms.Infrastructure.HostedServices; +using Umbraco.Cms.Tests.Common; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs; + +[TestFixture] +public class LogScrubberJobTests +{ + private Mock _mockAuditService; + + private const int MaxLogAgeInMinutes = 60; + + [Test] + public async Task Executes_And_Scrubs_Logs() + { + var sut = CreateLogScrubber(); + await sut.RunJobAsync(); + VerifyLogsScrubbed(); + } + + private LogScrubberJob CreateLogScrubber() + { + var settings = new LoggingSettings { MaxLogAge = TimeSpan.FromMinutes(MaxLogAgeInMinutes) }; + + var mockScope = new Mock(); + var mockScopeProvider = new Mock(); + mockScopeProvider + .Setup(x => x.CreateCoreScope( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(mockScope.Object); + var mockLogger = new Mock>(); + var mockProfilingLogger = new Mock(); + + _mockAuditService = new Mock(); + + return new LogScrubberJob( + _mockAuditService.Object, + new TestOptionsMonitor(settings), + mockScopeProvider.Object, + mockLogger.Object, + mockProfilingLogger.Object); + } + + private void VerifyLogsNotScrubbed() => VerifyLogsScrubbed(Times.Never()); + + private void VerifyLogsScrubbed() => VerifyLogsScrubbed(Times.Once()); + + private void VerifyLogsScrubbed(Times times) => + _mockAuditService.Verify(x => x.CleanLogs(It.Is(y => y == MaxLogAgeInMinutes)), times); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJobTests.cs new file mode 100644 index 0000000000..eb1f0695c8 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJobTests.cs @@ -0,0 +1,92 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Data; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Infrastructure; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; +using Umbraco.Cms.Infrastructure.HostedServices; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs; + +[TestFixture] +public class ScheduledPublishingJobTests +{ + private Mock _mockContentService; + private Mock> _mockLogger; + + [Test] + public async Task Does_Not_Execute_When_Not_Enabled() + { + var sut = CreateScheduledPublishing(enabled: false); + await sut.RunJobAsync(); + VerifyScheduledPublishingNotPerformed(); + } + + [Test] + public async Task Executes_And_Performs_Scheduled_Publishing() + { + var sut = CreateScheduledPublishing(); + await sut.RunJobAsync(); + VerifyScheduledPublishingPerformed(); + } + + private ScheduledPublishingJob CreateScheduledPublishing( + bool enabled = true) + { + if (enabled) + { + Suspendable.ScheduledPublishing.Resume(); + } + else + { + Suspendable.ScheduledPublishing.Suspend(); + } + + _mockContentService = new Mock(); + + var mockUmbracoContextFactory = new Mock(); + mockUmbracoContextFactory.Setup(x => x.EnsureUmbracoContext()) + .Returns(new UmbracoContextReference(null, false, null)); + + _mockLogger = new Mock>(); + + var mockServerMessenger = new Mock(); + + var mockScopeProvider = new Mock(); + mockScopeProvider + .Setup(x => x.CreateCoreScope( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Mock.Of()); + + return new ScheduledPublishingJob( + _mockContentService.Object, + mockUmbracoContextFactory.Object, + _mockLogger.Object, + mockServerMessenger.Object, + mockScopeProvider.Object); + } + + private void VerifyScheduledPublishingNotPerformed() => VerifyScheduledPublishingPerformed(Times.Never()); + + private void VerifyScheduledPublishingPerformed() => VerifyScheduledPublishingPerformed(Times.Once()); + + private void VerifyScheduledPublishingPerformed(Times times) => + _mockContentService.Verify(x => x.PerformScheduledPublish(It.IsAny()), times); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJobTests.cs new file mode 100644 index 0000000000..f2e6e574ef --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJobTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs.ServerRegistration; + +[TestFixture] +public class InstructionProcessJobTests +{ + private Mock _mockDatabaseServerMessenger; + + + [Test] + public async Task Executes_And_Touches_Server() + { + var sut = CreateInstructionProcessJob(); + await sut.RunJobAsync(); + VerifyMessengerSynced(); + } + + private InstructionProcessJob CreateInstructionProcessJob() + { + + var mockLogger = new Mock>(); + + _mockDatabaseServerMessenger = new Mock(); + + var settings = new GlobalSettings(); + + return new InstructionProcessJob( + _mockDatabaseServerMessenger.Object, + mockLogger.Object, + Options.Create(settings)); + } + + private void VerifyMessengerNotSynced() => VerifyMessengerSyncedTimes(Times.Never()); + + private void VerifyMessengerSynced() => VerifyMessengerSyncedTimes(Times.Once()); + + private void VerifyMessengerSyncedTimes(Times times) => _mockDatabaseServerMessenger.Verify(x => x.Sync(), times); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJobTests.cs new file mode 100644 index 0000000000..0da3b15e1b --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJobTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.Hosting; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs.ServerRegistration; +using Umbraco.Cms.Infrastructure.HostedServices.ServerRegistration; +using Umbraco.Cms.Tests.Common; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs.ServerRegistration; + +[TestFixture] +public class TouchServerJobTests +{ + private Mock _mockServerRegistrationService; + + private const string ApplicationUrl = "https://mysite.com/"; + private readonly TimeSpan _staleServerTimeout = TimeSpan.FromMinutes(2); + + + [Test] + public async Task Does_Not_Execute_When_Application_Url_Is_Not_Available() + { + var sut = CreateTouchServerTask(applicationUrl: string.Empty); + await sut.RunJobAsync(); + VerifyServerNotTouched(); + } + + [Test] + public async Task Executes_And_Touches_Server() + { + var sut = CreateTouchServerTask(); + await sut.RunJobAsync(); + VerifyServerTouched(); + } + + [Test] + public async Task Does_Not_Execute_When_Role_Accessor_Is_Not_Elected() + { + var sut = CreateTouchServerTask(useElection: false); + await sut.RunJobAsync(); + VerifyServerNotTouched(); + } + + private TouchServerJob CreateTouchServerTask( + RuntimeLevel runtimeLevel = RuntimeLevel.Run, + string applicationUrl = ApplicationUrl, + bool useElection = true) + { + var mockRequestAccessor = new Mock(); + mockRequestAccessor.SetupGet(x => x.ApplicationMainUrl) + .Returns(!string.IsNullOrEmpty(applicationUrl) ? new Uri(ApplicationUrl) : null); + + var mockRunTimeState = new Mock(); + mockRunTimeState.SetupGet(x => x.Level).Returns(runtimeLevel); + + var mockLogger = new Mock>(); + + _mockServerRegistrationService = new Mock(); + + var settings = new GlobalSettings + { + DatabaseServerRegistrar = new DatabaseServerRegistrarSettings { StaleServerTimeout = _staleServerTimeout }, + }; + + IServerRoleAccessor roleAccessor = useElection + ? new ElectedServerRoleAccessor(_mockServerRegistrationService.Object) + : new SingleServerRoleAccessor(); + + return new TouchServerJob( + _mockServerRegistrationService.Object, + mockRequestAccessor.Object, + mockLogger.Object, + new TestOptionsMonitor(settings), + roleAccessor); + } + + private void VerifyServerNotTouched() => VerifyServerTouchedTimes(Times.Never()); + + private void VerifyServerTouched() => VerifyServerTouchedTimes(Times.Once()); + + private void VerifyServerTouchedTimes(Times times) => _mockServerRegistrationService + .Verify( + x => x.TouchServer( + It.Is(y => y == ApplicationUrl), + It.Is(y => y == _staleServerTimeout)), + times); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJobTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJobTests.cs new file mode 100644 index 0000000000..c37094e6ac --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJobTests.cs @@ -0,0 +1,50 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Infrastructure.BackgroundJobs.Jobs; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs.Jobs +{ + [TestFixture] + public class TempFileCleanupJobTests + { + private Mock _mockIOHelper; + private readonly string _testPath = Path.Combine(TestContext.CurrentContext.TestDirectory.Split("bin")[0], "App_Data", "TEMP"); + + + [Test] + public async Task Executes_And_Cleans_Files() + { + TempFileCleanupJob sut = CreateTempFileCleanupJob(); + await sut.RunJobAsync(); + VerifyFilesCleaned(); + } + + private TempFileCleanupJob CreateTempFileCleanupJob() + { + + _mockIOHelper = new Mock(); + _mockIOHelper.Setup(x => x.GetTempFolders()) + .Returns(new DirectoryInfo[] { new(_testPath) }); + _mockIOHelper.Setup(x => x.CleanFolder(It.IsAny(), It.IsAny())) + .Returns(CleanFolderResult.Success()); + + var mockLogger = new Mock>(); + + return new TempFileCleanupJob(_mockIOHelper.Object,mockLogger.Object); + } + + private void VerifyFilesNotCleaned() => VerifyFilesCleaned(Times.Never()); + + private void VerifyFilesCleaned() => VerifyFilesCleaned(Times.Once()); + + private void VerifyFilesCleaned(Times times) => _mockIOHelper.Verify(x => x.CleanFolder(It.Is(y => y.FullName == _testPath), It.IsAny()), times); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceTests.cs new file mode 100644 index 0000000000..806520bf41 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceTests.cs @@ -0,0 +1,208 @@ +// Copyright (c) Umbraco. +// See LICENSE for more details. + +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Configuration; +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Runtime; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Infrastructure.BackgroundJobs; +using Umbraco.Cms.Infrastructure.HostedServices; +using Umbraco.Cms.Infrastructure.Notifications; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.BackgroundJobs; + +[TestFixture] +public class RecurringBackgroundJobHostedServiceTests +{ + + [TestCase(RuntimeLevel.Boot)] + [TestCase(RuntimeLevel.Install)] + [TestCase(RuntimeLevel.Unknown)] + [TestCase(RuntimeLevel.Upgrade)] + [TestCase(RuntimeLevel.BootFailed)] + public async Task Does_Not_Execute_When_Runtime_State_Is_Not_Run(RuntimeLevel runtimeLevel) + { + var mockJob = new Mock(); + + var sut = CreateRecurringBackgroundJobHostedService(mockJob, runtimeLevel: runtimeLevel); + await sut.PerformExecuteAsync(null); + + mockJob.Verify(job => job.RunJobAsync(), Times.Never); + } + + [Test] + public async Task Publishes_Ignored_Notification_When_Runtime_State_Is_Not_Run() + { + var mockJob = new Mock(); + var mockEventAggregator = new Mock(); + + var sut = CreateRecurringBackgroundJobHostedService(mockJob, runtimeLevel: RuntimeLevel.Unknown, mockEventAggregator: mockEventAggregator); + await sut.PerformExecuteAsync(null); + + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [TestCase(ServerRole.Unknown)] + [TestCase(ServerRole.Subscriber)] + public async Task Does_Not_Execute_When_Server_Role_Is_NotDefault(ServerRole serverRole) + { + var mockJob = new Mock(); + + var sut = CreateRecurringBackgroundJobHostedService(mockJob, serverRole: serverRole); + await sut.PerformExecuteAsync(null); + + mockJob.Verify(job => job.RunJobAsync(), Times.Never); + } + + [TestCase(ServerRole.Single)] + [TestCase(ServerRole.SchedulingPublisher)] + public async Task Does_Executes_When_Server_Role_Is_Default(ServerRole serverRole) + { + var mockJob = new Mock(); + mockJob.Setup(x => x.ServerRoles).Returns(IRecurringBackgroundJob.DefaultServerRoles); + + var sut = CreateRecurringBackgroundJobHostedService(mockJob, serverRole: serverRole); + await sut.PerformExecuteAsync(null); + + mockJob.Verify(job => job.RunJobAsync(), Times.Once); + } + + [Test] + public async Task Does_Execute_When_Server_Role_Is_Subscriber_And_Specified() + { + var mockJob = new Mock(); + mockJob.Setup(x => x.ServerRoles).Returns(new ServerRole[] { ServerRole.Subscriber }); + + var sut = CreateRecurringBackgroundJobHostedService(mockJob, serverRole: ServerRole.Subscriber); + await sut.PerformExecuteAsync(null); + + mockJob.Verify(job => job.RunJobAsync(), Times.Once); + } + + [Test] + public async Task Publishes_Ignored_Notification_When_Server_Role_Is_Not_Allowed() + { + var mockJob = new Mock(); + var mockEventAggregator = new Mock(); + + var sut = CreateRecurringBackgroundJobHostedService(mockJob, serverRole: ServerRole.Unknown, mockEventAggregator: mockEventAggregator); + await sut.PerformExecuteAsync(null); + + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Does_Not_Execute_When_Not_Main_Dom() + { + var mockJob = new Mock(); + + var sut = CreateRecurringBackgroundJobHostedService(mockJob, isMainDom: false); + await sut.PerformExecuteAsync(null); + + mockJob.Verify(job => job.RunJobAsync(), Times.Never); + } + + [Test] + public async Task Publishes_Ignored_Notification_When_Not_Main_Dom() + { + var mockJob = new Mock(); + var mockEventAggregator = new Mock(); + + var sut = CreateRecurringBackgroundJobHostedService(mockJob, isMainDom: false, mockEventAggregator: mockEventAggregator); + await sut.PerformExecuteAsync(null); + + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + + + [Test] + public async Task Publishes_Executed_Notification_When_Run() + { + var mockJob = new Mock(); + mockJob.Setup(x => x.ServerRoles).Returns(IRecurringBackgroundJob.DefaultServerRoles); + var mockEventAggregator = new Mock(); + + var sut = CreateRecurringBackgroundJobHostedService(mockJob, mockEventAggregator: mockEventAggregator); + await sut.PerformExecuteAsync(null); + + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Publishes_Failed_Notification_When_Fails() + { + var mockJob = new Mock(); + mockJob.Setup(x => x.ServerRoles).Returns(IRecurringBackgroundJob.DefaultServerRoles); + mockJob.Setup(x => x.RunJobAsync()).Throws(); + var mockEventAggregator = new Mock(); + + var sut = CreateRecurringBackgroundJobHostedService(mockJob, mockEventAggregator: mockEventAggregator); + await sut.PerformExecuteAsync(null); + + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task Publishes_Start_And_Stop_Notifications() + { + var mockJob = new Mock(); + var mockEventAggregator = new Mock(); + + var sut = CreateRecurringBackgroundJobHostedService(mockJob, isMainDom: false, mockEventAggregator: mockEventAggregator); + await sut.StartAsync(CancellationToken.None); + await sut.StopAsync(CancellationToken.None); + + + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + + + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + mockEventAggregator.Verify(x => x.PublishAsync(It.IsAny(), It.IsAny()), Times.Once); + + } + + + private RecurringHostedServiceBase CreateRecurringBackgroundJobHostedService( + Mock mockJob, + RuntimeLevel runtimeLevel = RuntimeLevel.Run, + ServerRole serverRole = ServerRole.Single, + bool isMainDom = true, + Mock mockEventAggregator = null) + { + var mockRunTimeState = new Mock(); + mockRunTimeState.SetupGet(x => x.Level).Returns(runtimeLevel); + + var mockServerRegistrar = new Mock(); + mockServerRegistrar.Setup(x => x.CurrentServerRole).Returns(serverRole); + + var mockMainDom = new Mock(); + mockMainDom.SetupGet(x => x.IsMainDom).Returns(isMainDom); + + var mockLogger = new Mock>>(); + if (mockEventAggregator == null) + { + mockEventAggregator = new Mock(); + } + + return new RecurringBackgroundJobHostedService( + mockRunTimeState.Object, + mockLogger.Object, + mockMainDom.Object, + mockServerRegistrar.Object, + mockEventAggregator.Object, + mockJob.Object); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/HealthCheckNotifierTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/HealthCheckNotifierTests.cs index 626129b3b7..03d7f344a6 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/HealthCheckNotifierTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/HealthCheckNotifierTests.cs @@ -23,6 +23,7 @@ using Umbraco.Cms.Tests.Common; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices; [TestFixture] +[Obsolete("Replaced by BackgroundJobs.Jobs.HealthCheckNotifierJobTests")] public class HealthCheckNotifierTests { private Mock _mockNotificationMethod; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/KeepAliveTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/KeepAliveTests.cs index f0ef4cd278..4631bb21a1 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/KeepAliveTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/KeepAliveTests.cs @@ -21,6 +21,7 @@ using Umbraco.Cms.Tests.Common; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices; [TestFixture] +[Obsolete("Replaced by BackgroundJobs.Jobs.KeepAliveJobTests")] public class KeepAliveTests { private Mock _mockHttpMessageHandler; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/LogScrubberTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/LogScrubberTests.cs index 553a4f451c..98fdf4c453 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/LogScrubberTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/LogScrubberTests.cs @@ -19,6 +19,7 @@ using Umbraco.Cms.Tests.Common; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices; [TestFixture] +[Obsolete("Replaced by BackgroundJobs.Jobs.LogScrubberJobTests")] public class LogScrubberTests { private Mock _mockAuditService; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ScheduledPublishingTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ScheduledPublishingTests.cs index 609dfbb7fa..3eb7756d7f 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ScheduledPublishingTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ScheduledPublishingTests.cs @@ -19,6 +19,7 @@ using Umbraco.Cms.Infrastructure.HostedServices; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices; [TestFixture] +[Obsolete("Replaced by BackgroundJobs.Jobs.ScheduledPublishingJobTests")] public class ScheduledPublishingTests { private Mock _mockContentService; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTaskTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTaskTests.cs index fd24d60019..1513c6a5d4 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTaskTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/InstructionProcessTaskTests.cs @@ -15,6 +15,7 @@ using Umbraco.Cms.Infrastructure.HostedServices.ServerRegistration; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices.ServerRegistration; [TestFixture] +[Obsolete("Replaced by BackgroundJobs.Jobs.ServerRegistration.InstructionProcessJobTests")] public class InstructionProcessTaskTests { private Mock _mockDatabaseServerMessenger; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTaskTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTaskTests.cs index b379f8d34b..91d156e519 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTaskTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/ServerRegistration/TouchServerTaskTests.cs @@ -16,6 +16,7 @@ using Umbraco.Cms.Tests.Common; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices.ServerRegistration; [TestFixture] +[Obsolete("Replaced by BackgroundJobs.Jobs.ServerRegistration.TouchServerJobTests")] public class TouchServerTaskTests { private Mock _mockServerRegistrationService; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/TempFileCleanupTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/TempFileCleanupTests.cs index 851afc269b..2128f917b9 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/TempFileCleanupTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/HostedServices/TempFileCleanupTests.cs @@ -13,6 +13,7 @@ using Umbraco.Cms.Infrastructure.HostedServices; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.HostedServices { [TestFixture] + [Obsolete("Replaced by BackgroundJobs.Jobs.TempFileCleanupTests")] public class TempFileCleanupTests { private Mock _mockIOHelper; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj index fbf1f8838e..fc4734bd6d 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/umbraco.sln b/umbraco.sln index ec82c1c34d..a129cdb6ef 100644 --- a/umbraco.sln +++ b/umbraco.sln @@ -139,6 +139,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution LICENSE.md = LICENSE.md umbraco.sln.DotSettings = umbraco.sln.DotSettings version.json = version.json + global.json = global.json EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{20CE9C97-9314-4A19-BCF1-D12CF49B7205}"