From 1fcb96288f2032f43fbd0946d9c19a299430c27f Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Thu, 9 Nov 2023 11:14:55 +0100 Subject: [PATCH 01/36] correct localization key of 2fa view --- .../src/components/pages/mfa.page.element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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

From f8c1a560ffb0da45b2ceedbff835259efc3f2abb Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Thu, 9 Nov 2023 13:36:57 +0100 Subject: [PATCH 02/36] Bump version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 7b6fdb03be..d733b40e8f 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "10.7.0", + "version": "10.8.0", "assemblyVersion": { "precision": "build" }, From d5b1f5875b295e3b5df2e610cfa8cb9a3b4d8594 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Thu, 9 Nov 2023 13:38:13 +0100 Subject: [PATCH 03/36] Bump version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index d733b40e8f..364e6e8834 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "10.8.0", + "version": "10.9.0-rc", "assemblyVersion": { "precision": "build" }, From ddb26632f9d318e832fa90f9472c236b50720633 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Thu, 9 Nov 2023 13:38:34 +0100 Subject: [PATCH 04/36] Bump to rc --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index d733b40e8f..3c0aeb9872 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "10.8.0", + "version": "10.8.0-rc", "assemblyVersion": { "precision": "build" }, From d0be4ab263d3996da9ee68456012b6c70c5dba92 Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Thu, 9 Nov 2023 13:51:05 +0100 Subject: [PATCH 05/36] Publish ContentTreeChangeNotification after saving/deleting blueprints (#14898) --- src/Umbraco.Core/Services/ContentService.cs | 3 +++ .../Cache/DistributedCacheBinder_Handlers.cs | 9 --------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/src/Umbraco.Core/Services/ContentService.cs b/src/Umbraco.Core/Services/ContentService.cs index 262598451a..f324384a3d 100644 --- a/src/Umbraco.Core/Services/ContentService.cs +++ b/src/Umbraco.Core/Services/ContentService.cs @@ -3568,6 +3568,7 @@ public class ContentService : RepositoryService, IContentService Audit(AuditType.Save, Constants.Security.SuperUserId, 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(); } @@ -3582,6 +3583,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(); } } @@ -3682,6 +3684,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.Infrastructure/Cache/DistributedCacheBinder_Handlers.cs b/src/Umbraco.Infrastructure/Cache/DistributedCacheBinder_Handlers.cs index 11119aaf66..0e84c747ac 100644 --- a/src/Umbraco.Infrastructure/Cache/DistributedCacheBinder_Handlers.cs +++ b/src/Umbraco.Infrastructure/Cache/DistributedCacheBinder_Handlers.cs @@ -67,15 +67,6 @@ public class DistributedCacheBinder : _distributedCache.RefreshContentCache(notification.Changes.ToArray()); } - // private void ContentService_SavedBlueprint(IContentService sender, SaveEventArgs e) - // { - // _distributedCache.RefreshUnpublishedPageCache(e.SavedEntities.ToArray()); - // } - - // private void ContentService_DeletedBlueprint(IContentService sender, DeleteEventArgs e) - // { - // _distributedCache.RemoveUnpublishedPageCache(e.DeletedEntities.ToArray()); - // } #endregion #region LocalizationService / Dictionary From ed65645a33ed884ca4fecb419205d9a243f7da3c Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Thu, 9 Nov 2023 13:52:21 +0100 Subject: [PATCH 06/36] Add global.json and cleanup UI project file (#14900) * Add global.json with minimum .NET 6.0.5 SDK version and roll forward policy * Clean up UI project file --- global.json | 6 ++++++ src/Umbraco.Web.UI/Umbraco.Web.UI.csproj | 8 -------- 2 files changed, 6 insertions(+), 8 deletions(-) create mode 100644 global.json diff --git a/global.json b/global.json new file mode 100644 index 0000000000..49d225529c --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "6.0.300", + "rollForward": "latestFeature" + } +} diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index a205bd0efb..b7be2ba90f 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -11,14 +11,6 @@ - - - - - - - - From 919138c299b69e46af19828209fc1062e3dbf4bb Mon Sep 17 00:00:00 2001 From: Rasmus Berntsen Date: Thu, 9 Nov 2023 13:53:18 +0100 Subject: [PATCH 07/36] #14835 Update link on permissionsreport.html (#14872) * #14835 Update link on permissionsreport.html Updated link to permissions page to point to 10-LTS documentation * retrigger checks --------- Co-authored-by: Rasmus Berntsen --- .../src/installer/steps/permissionsreport.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From 7bde16b4ef663306eb4edf306f23ccc443b8f74e Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Thu, 9 Nov 2023 14:18:34 +0100 Subject: [PATCH 08/36] V13: Add eventype to webhookevents (#15157) * Refactor IWebhookEvent to contain event type. * refactor frontend to filter on eventType. * Display event names * refactor to use eventNames * remove npm from overview * implement alias for WebhookEvents * Implement [WebhookEvent] attribute * Refactor IWebhookService to get by event alias and not name * Rename parameter to fit method name * to lower event type to avoid casing issues * Apply suggestions from code review Co-authored-by: Ronald Barendse * Change event names from constants to hard coded. And give more friendly names * Refactor to not use event names, where it was not intended * Add renaming column migration * display event alias in logs * Update migration to check if old column is there * Apply suggestions from code review Co-authored-by: Kenn Jacobsen * add determineResource function to avoid duplicate code --------- Co-authored-by: Zeegaan Co-authored-by: Ronald Barendse Co-authored-by: Kenn Jacobsen --- src/Umbraco.Core/Constants-WebhookEvents.cs | 66 +++++++++++++------ src/Umbraco.Core/Models/WebhookLog.cs | 2 +- .../Repositories/IWebhookRepository.cs | 8 ++- src/Umbraco.Core/Services/IWebHookService.cs | 2 +- .../Services/IWebhookFiringService.cs | 2 +- .../Services/IWebhookLogFactory.cs | 2 +- .../Services/WebhookLogFactory.cs | 4 +- src/Umbraco.Core/Services/WebhookService.cs | 4 +- .../Events/ContentDeleteWebhookEvent.cs | 6 +- .../Events/ContentPublishWebhookEvent.cs | 6 +- .../Events/ContentUnpublishWebhookEvent.cs | 6 +- .../Events/MediaDeleteWebhookEvent.cs | 6 +- .../Webhooks/Events/MediaSaveWebhookEvent.cs | 6 +- src/Umbraco.Core/Webhooks/IWebhookEvent.cs | 4 ++ .../Webhooks/WebhookEventAttribute.cs | 26 ++++++++ src/Umbraco.Core/Webhooks/WebhookEventBase.cs | 26 ++++++-- .../Webhooks/WebhookEventContentBase.cs | 7 +- .../Migrations/Upgrade/UmbracoPlan.cs | 1 + .../Upgrade/V_13_0_0/RenameEventNameColumn.cs | 28 ++++++++ .../Persistence/Dtos/WebhookLogDto.cs | 4 +- .../Factories/WebhookLogFactory.cs | 4 +- .../Implement/WebhookRepository.cs | 4 +- .../Implement/WebhookFiringService.cs | 10 +-- .../Controllers/WebhookController.cs | 7 +- .../UmbracoBuilderExtensions.cs | 1 + .../Mapping/WebhookMapDefinition.cs | 19 ++---- .../Services/IWebhookPresentationFactory.cs | 10 +++ .../Services/WebhookPresentationFactory.cs | 37 +++++++++++ .../Models/WebhookEventViewModel.cs | 7 ++ .../Models/WebhookLogViewModel.cs | 4 +- .../Models/WebhookViewModel.cs | 2 +- .../eventpicker/eventpicker.controller.js | 28 ++++---- .../eventpicker/eventpicker.html | 2 +- .../src/views/webhooks/logs.html | 2 +- .../src/views/webhooks/overlays/details.html | 2 +- .../webhooks/overlays/edit.controller.js | 30 ++++++--- .../src/views/webhooks/overlays/edit.html | 6 +- .../src/views/webhooks/webhooks.controller.js | 37 ++++++++--- .../Services/WebhookLogServiceTests.cs | 4 +- .../Services/WebhookServiceTests.cs | 42 ++++++------ 40 files changed, 329 insertions(+), 145 deletions(-) create mode 100644 src/Umbraco.Core/Webhooks/WebhookEventAttribute.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/RenameEventNameColumn.cs create mode 100644 src/Umbraco.Web.BackOffice/Services/IWebhookPresentationFactory.cs create mode 100644 src/Umbraco.Web.BackOffice/Services/WebhookPresentationFactory.cs 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/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/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/Services/IWebHookService.cs b/src/Umbraco.Core/Services/IWebHookService.cs index 84e5319fe1..785f5e8701 100644 --- a/src/Umbraco.Core/Services/IWebHookService.cs +++ b/src/Umbraco.Core/Services/IWebHookService.cs @@ -36,5 +36,5 @@ public interface IWebHookService /// /// Gets webhooks by event name. /// - Task> GetByEventNameAsync(string eventName); + Task> GetByAliasAsync(string alias); } 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/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/WebhookService.cs b/src/Umbraco.Core/Services/WebhookService.cs index 9813199db8..424f1afb14 100644 --- a/src/Umbraco.Core/Services/WebhookService.cs +++ b/src/Umbraco.Core/Services/WebhookService.cs @@ -80,10 +80,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/Webhooks/Events/ContentDeleteWebhookEvent.cs b/src/Umbraco.Core/Webhooks/Events/ContentDeleteWebhookEvent.cs index 52b8d233e5..d8386b9914 100644 --- a/src/Umbraco.Core/Webhooks/Events/ContentDeleteWebhookEvent.cs +++ b/src/Umbraco.Core/Webhooks/Events/ContentDeleteWebhookEvent.cs @@ -7,6 +7,7 @@ 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( @@ -18,11 +19,12 @@ public class ContentDeleteWebhookEvent : WebhookEventContentBase 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..03f2e71706 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; @@ -26,13 +27,14 @@ public class ContentPublishWebhookEvent : WebhookEventContentBase 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..354cdb295b 100644 --- a/src/Umbraco.Core/Webhooks/Events/ContentUnpublishWebhookEvent.cs +++ b/src/Umbraco.Core/Webhooks/Events/ContentUnpublishWebhookEvent.cs @@ -7,6 +7,7 @@ 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( @@ -18,11 +19,12 @@ public class ContentUnpublishWebhookEvent : WebhookEventContentBase 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..ab0fd98942 100644 --- a/src/Umbraco.Core/Webhooks/Events/MediaDeleteWebhookEvent.cs +++ b/src/Umbraco.Core/Webhooks/Events/MediaDeleteWebhookEvent.cs @@ -7,6 +7,7 @@ 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( @@ -18,11 +19,12 @@ public class MediaDeleteWebhookEvent : WebhookEventContentBase 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..6907067b87 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; @@ -26,13 +27,14 @@ public class MediaSaveWebhookEvent : WebhookEventContentBase 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..cd1666135a 100644 --- a/src/Umbraco.Core/Webhooks/WebhookEventBase.cs +++ b/src/Umbraco.Core/Webhooks/WebhookEventBase.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; @@ -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 WebhookSettings WebhookSettings { get; private set; } + + protected WebhookEventBase( IWebhookFiringService webhookFiringService, IWebHookService webHookService, IOptionsMonitor webhookSettings, - IServerRoleAccessor serverRoleAccessor, - string eventName) + IServerRoleAccessor serverRoleAccessor) { - EventName = eventName; WebhookFiringService = webhookFiringService; 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..3f7cd4c7b2 100644 --- a/src/Umbraco.Core/Webhooks/WebhookEventContentBase.cs +++ b/src/Umbraco.Core/Webhooks/WebhookEventContentBase.cs @@ -16,9 +16,8 @@ public abstract class WebhookEventContentBase : WebhookE IWebhookFiringService webhookFiringService, 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/Migrations/Upgrade/UmbracoPlan.cs b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs index 306f7869f7..3a4e715228 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -97,5 +97,6 @@ public class UmbracoPlan : MigrationPlan // To 13.0.0 To("{C76D9C9A-635B-4D2C-A301-05642A523E9D}"); + To("{D5139400-E507-4259-A542-C67358F7E329}"); } } 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/Persistence/Dtos/WebhookLogDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs index a8606c7391..a9588ecb7d 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs @@ -33,9 +33,9 @@ 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)] 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/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/Services/Implement/WebhookFiringService.cs b/src/Umbraco.Infrastructure/Services/Implement/WebhookFiringService.cs index cf09c0d3a2..67b55c913e 100644 --- a/src/Umbraco.Infrastructure/Services/Implement/WebhookFiringService.cs +++ b/src/Umbraco.Infrastructure/Services/Implement/WebhookFiringService.cs @@ -29,11 +29,11 @@ public class WebhookFiringService : IWebhookFiringService // 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) + public async Task FireAsync(Webhook webhook, string eventAlias, object? payload, CancellationToken cancellationToken) { for (var retry = 0; retry < _webhookSettings.MaximumRetries; retry++) { - HttpResponseMessage response = await SendRequestAsync(webhook, eventName, payload, retry, cancellationToken); + HttpResponseMessage response = await SendRequestAsync(webhook, eventAlias, payload, retry, cancellationToken); if (response.IsSuccessStatusCode) { @@ -42,13 +42,13 @@ public class WebhookFiringService : IWebhookFiringService } } - private async Task SendRequestAsync(Webhook webhook, string eventName, object? payload, int retryCount, CancellationToken cancellationToken) + private async Task SendRequestAsync(Webhook webhook, string eventAlias, 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); + stringContent.Headers.TryAddWithoutValidation("Umb-Webhook-Event", eventAlias); foreach (KeyValuePair header in webhook.Headers) { @@ -64,7 +64,7 @@ public class WebhookFiringService : IWebhookFiringService }; - WebhookLog log = await _webhookLogFactory.CreateAsync(eventName, webhookResponseModel, webhook, cancellationToken); + WebhookLog log = await _webhookLogFactory.CreateAsync(eventAlias, webhookResponseModel, webhook, cancellationToken); await _webhookLogService.CreateAsync(log); return response; diff --git a/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs b/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs index 9be5372ce5..49b80144a7 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs @@ -4,6 +4,7 @@ using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Webhooks; +using Umbraco.Cms.Web.BackOffice.Services; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Models; @@ -16,13 +17,15 @@ public class WebhookController : UmbracoAuthorizedJsonController 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; _umbracoMapper = umbracoMapper; _webhookEventCollection = webhookEventCollection; _webhookLogService = webhookLogService; + _webhookPresentationFactory = webhookPresentationFactory; } [HttpGet] @@ -30,7 +33,7 @@ public class WebhookController : UmbracoAuthorizedJsonController { PagedModel webhooks = await _webHookService.GetAllAsync(skip, take); - List webhookViewModels = _umbracoMapper.MapEnumerable(webhooks.Items); + IEnumerable webhookViewModels = webhooks.Items.Select(_webhookPresentationFactory.Create); return Ok(webhookViewModels); } diff --git a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs index c973963495..dfa22b06e8 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs @@ -120,6 +120,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..57ef7e5f2a --- /dev/null +++ b/src/Umbraco.Web.BackOffice/Services/WebhookPresentationFactory.cs @@ -0,0 +1,37 @@ +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, + 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/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..9030ff050c 100644 --- a/src/Umbraco.Web.Common/Models/WebhookViewModel.cs +++ b/src/Umbraco.Web.Common/Models/WebhookViewModel.cs @@ -12,7 +12,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.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/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 index 9df465f738..6f8a17ceb8 100644 --- 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 @@ -1,7 +1,7 @@ -(function () { +(function () { "use strict"; - function EditController($scope, editorService, contentTypeResource, mediaTypeResource) { + function EditController($scope, editorService, contentTypeResource, mediaTypeResource, memberTypeResource) { const vm = this; @@ -29,7 +29,7 @@ } function openContentTypePicker() { - const isContent = $scope.model.webhook ? $scope.model.webhook.events[0].toLowerCase().includes("content") : null; + const eventType = $scope.model.webhook ? $scope.model.webhook.events[0].eventType.toLowerCase() : null; const editor = { multiPicker: true, @@ -39,7 +39,7 @@ return item.nodeType === "container"; // || item.metaData.isElement || !!_.findWhere(vm.itemTypes, { udi: item.udi }); }, submit(model) { - getEntities(model.selection, isContent); + getEntities(model.selection, eventType); $scope.model.webhook.contentTypeKeys = model.selection.map(item => item.key); editorService.close(); }, @@ -48,9 +48,7 @@ } }; - const itemType = isContent ? "content" : "media"; - - switch (itemType) { + switch (eventType.toLowerCase()) { case "content": editorService.contentTypePicker(editor); break; @@ -82,8 +80,22 @@ }); } - function getEntities(selection, isContent) { - const resource = isContent ? contentTypeResource : mediaTypeResource; + 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; + } + $scope.model.contentTypes = []; selection.forEach(entity => { 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 index cf55320f2f..4bc61fec98 100644 --- a/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.html +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.html @@ -42,9 +42,9 @@ { - 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 determineResource(resourceType){ + let resource; + switch (resourceType) { + case "content": + resource = contentTypeResource; + break; + case "media": + resource = mediaTypeResource; + break; + case "member": + resource = memberTypeResource; + break; + default: + return; + } + + return resource; + } + function getEntities(webhook) { - const isContent = webhook.events[0].toLowerCase().includes("content"); - const resource = isContent ? contentTypeResource : mediaTypeResource; + let resource = determineResource(webhook.events[0].eventType.toLowerCase()); let entities = []; webhook.contentTypeKeys.forEach(key => { @@ -68,8 +86,7 @@ } 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]; @@ -154,7 +171,7 @@ } function loadWebhooks(){ - webhooksResource + return webhooksResource .getAll() .then(result => { vm.webhooks = result; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookLogServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookLogServiceTests.cs index af75becd1c..b4959de8e9 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", @@ -39,7 +39,7 @@ public class WebhookLogServiceTests : UmbracoIntegrationTest 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.EventAlias, webHookLog.EventAlias); Assert.AreEqual(createdWebhookLog.RequestBody, webHookLog.RequestBody); Assert.AreEqual(createdWebhookLog.ResponseBody, webHookLog.ResponseBody); Assert.AreEqual(createdWebhookLog.StatusCode, webHookLog.StatusCode); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookServiceTests.cs index 6f6da74485..609388dfaf 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookServiceTests.cs @@ -14,11 +14,11 @@ public class WebhookServiceTests : UmbracoIntegrationTest 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 })); @@ -37,9 +37,9 @@ 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(() => @@ -52,11 +52,11 @@ public class WebhookServiceTests : UmbracoIntegrationTest } [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 })); @@ -71,7 +71,7 @@ public class WebhookServiceTests : UmbracoIntegrationTest [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 createdWebhook = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.Aliases.ContentPublish })); var webhook = await WebhookService.GetAsync(createdWebhook.Key); Assert.IsNotNull(webhook); @@ -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 }; + var createdWebhook = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.Aliases.ContentPublish })); + createdWebhook.Events = new[] { Constants.WebhookEvents.Aliases.ContentDelete }; await WebhookService.UpdateAsync(createdWebhook); var updatedWebhook = await WebhookService.GetAsync(createdWebhook.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()); From e143133bcfb955fd593314a28a405c042f4ed62b Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Fri, 10 Nov 2023 08:20:22 +0100 Subject: [PATCH 09/36] V13: Update IWebHookService to proper casing (#15169) * Update to proper casing * Fixed naming --------- Co-authored-by: Zeegaan Co-authored-by: Andreas Zerbst --- .../DependencyInjection/UmbracoBuilder.cs | 2 +- .../{IWebHookService.cs => IWebhookService.cs} | 2 +- src/Umbraco.Core/Services/WebhookService.cs | 2 +- .../Webhooks/Events/ContentDeleteWebhookEvent.cs | 4 ++-- .../Events/ContentPublishWebhookEvent.cs | 4 ++-- .../Events/ContentUnpublishWebhookEvent.cs | 4 ++-- .../Webhooks/Events/MediaDeleteWebhookEvent.cs | 4 ++-- .../Webhooks/Events/MediaSaveWebhookEvent.cs | 4 ++-- src/Umbraco.Core/Webhooks/WebhookEventBase.cs | 8 ++++---- .../Webhooks/WebhookEventContentBase.cs | 4 ++-- .../Controllers/WebhookController.cs | 16 ++++++++-------- .../src/views/webhooks/webhooks.controller.js | 14 +++++++------- .../src/views/webhooks/webhooks.html | 2 +- .../Services/WebhookLogServiceTests.cs | 16 ++++++++-------- .../Umbraco.Core/Services/WebhookServiceTests.cs | 2 +- 15 files changed, 44 insertions(+), 44 deletions(-) rename src/Umbraco.Core/Services/{IWebHookService.cs => IWebhookService.cs} (96%) diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 0e132d3fed..354ef7a40c 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -332,7 +332,7 @@ namespace Umbraco.Cms.Core.DependencyInjection // Register filestream security analyzers Services.AddUnique(); Services.AddUnique(); - Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); } diff --git a/src/Umbraco.Core/Services/IWebHookService.cs b/src/Umbraco.Core/Services/IWebhookService.cs similarity index 96% rename from src/Umbraco.Core/Services/IWebHookService.cs rename to src/Umbraco.Core/Services/IWebhookService.cs index 785f5e8701..d38adaa999 100644 --- a/src/Umbraco.Core/Services/IWebHookService.cs +++ b/src/Umbraco.Core/Services/IWebhookService.cs @@ -2,7 +2,7 @@ using Umbraco.Cms.Core.Models; namespace Umbraco.Cms.Core.Services; -public interface IWebHookService +public interface IWebhookService { /// /// Creates a webhook. diff --git a/src/Umbraco.Core/Services/WebhookService.cs b/src/Umbraco.Core/Services/WebhookService.cs index 424f1afb14..e6d5f405b4 100644 --- a/src/Umbraco.Core/Services/WebhookService.cs +++ b/src/Umbraco.Core/Services/WebhookService.cs @@ -4,7 +4,7 @@ using Umbraco.Cms.Core.Scoping; namespace Umbraco.Cms.Core.Services; -public class WebhookService : IWebHookService +public class WebhookService : IWebhookService { private readonly ICoreScopeProvider _provider; private readonly IWebhookRepository _webhookRepository; diff --git a/src/Umbraco.Core/Webhooks/Events/ContentDeleteWebhookEvent.cs b/src/Umbraco.Core/Webhooks/Events/ContentDeleteWebhookEvent.cs index d8386b9914..85a1f39ba9 100644 --- a/src/Umbraco.Core/Webhooks/Events/ContentDeleteWebhookEvent.cs +++ b/src/Umbraco.Core/Webhooks/Events/ContentDeleteWebhookEvent.cs @@ -12,12 +12,12 @@ public class ContentDeleteWebhookEvent : WebhookEventContentBase webhookSettings, IServerRoleAccessor serverRoleAccessor) : base( webhookFiringService, - webHookService, + webhookService, webhookSettings, serverRoleAccessor) { diff --git a/src/Umbraco.Core/Webhooks/Events/ContentPublishWebhookEvent.cs b/src/Umbraco.Core/Webhooks/Events/ContentPublishWebhookEvent.cs index 03f2e71706..e1ba7125ec 100644 --- a/src/Umbraco.Core/Webhooks/Events/ContentPublishWebhookEvent.cs +++ b/src/Umbraco.Core/Webhooks/Events/ContentPublishWebhookEvent.cs @@ -18,14 +18,14 @@ public class ContentPublishWebhookEvent : WebhookEventContentBase webhookSettings, IServerRoleAccessor serverRoleAccessor, IPublishedSnapshotAccessor publishedSnapshotAccessor, IApiContentBuilder apiContentBuilder) : base( webhookFiringService, - webHookService, + webhookService, webhookSettings, serverRoleAccessor) { diff --git a/src/Umbraco.Core/Webhooks/Events/ContentUnpublishWebhookEvent.cs b/src/Umbraco.Core/Webhooks/Events/ContentUnpublishWebhookEvent.cs index 354cdb295b..76499eb277 100644 --- a/src/Umbraco.Core/Webhooks/Events/ContentUnpublishWebhookEvent.cs +++ b/src/Umbraco.Core/Webhooks/Events/ContentUnpublishWebhookEvent.cs @@ -12,12 +12,12 @@ public class ContentUnpublishWebhookEvent : WebhookEventContentBase webhookSettings, IServerRoleAccessor serverRoleAccessor) : base( webhookFiringService, - webHookService, + webhookService, webhookSettings, serverRoleAccessor) { diff --git a/src/Umbraco.Core/Webhooks/Events/MediaDeleteWebhookEvent.cs b/src/Umbraco.Core/Webhooks/Events/MediaDeleteWebhookEvent.cs index ab0fd98942..ba9fb2333a 100644 --- a/src/Umbraco.Core/Webhooks/Events/MediaDeleteWebhookEvent.cs +++ b/src/Umbraco.Core/Webhooks/Events/MediaDeleteWebhookEvent.cs @@ -12,12 +12,12 @@ public class MediaDeleteWebhookEvent : WebhookEventContentBase webhookSettings, IServerRoleAccessor serverRoleAccessor) : base( webhookFiringService, - webHookService, + webhookService, webhookSettings, serverRoleAccessor) { diff --git a/src/Umbraco.Core/Webhooks/Events/MediaSaveWebhookEvent.cs b/src/Umbraco.Core/Webhooks/Events/MediaSaveWebhookEvent.cs index 6907067b87..cea7181d51 100644 --- a/src/Umbraco.Core/Webhooks/Events/MediaSaveWebhookEvent.cs +++ b/src/Umbraco.Core/Webhooks/Events/MediaSaveWebhookEvent.cs @@ -18,14 +18,14 @@ public class MediaSaveWebhookEvent : WebhookEventContentBase webhookSettings, IServerRoleAccessor serverRoleAccessor, IPublishedSnapshotAccessor publishedSnapshotAccessor, IApiMediaBuilder apiMediaBuilder) : base( webhookFiringService, - webHookService, + webhookService, webhookSettings, serverRoleAccessor) { diff --git a/src/Umbraco.Core/Webhooks/WebhookEventBase.cs b/src/Umbraco.Core/Webhooks/WebhookEventBase.cs index cd1666135a..de15e23a00 100644 --- a/src/Umbraco.Core/Webhooks/WebhookEventBase.cs +++ b/src/Umbraco.Core/Webhooks/WebhookEventBase.cs @@ -23,7 +23,7 @@ public abstract class WebhookEventBase : IWebhookEvent, INotifica protected IWebhookFiringService WebhookFiringService { get; } - protected IWebHookService WebHookService { get; } + protected IWebhookService WebhookService { get; } protected WebhookSettings WebhookSettings { get; private set; } @@ -31,13 +31,13 @@ public abstract class WebhookEventBase : IWebhookEvent, INotifica protected WebhookEventBase( IWebhookFiringService webhookFiringService, - IWebHookService webHookService, + IWebhookService webhookService, IOptionsMonitor webhookSettings, IServerRoleAccessor serverRoleAccessor) { WebhookFiringService = webhookFiringService; - WebHookService = webHookService; + WebhookService = webhookService; _serverRoleAccessor = serverRoleAccessor; // assign properties based on the attribute, if it is found @@ -91,7 +91,7 @@ public abstract class WebhookEventBase : IWebhookEvent, INotifica return; } - IEnumerable webhooks = await WebHookService.GetByAliasAsync(Alias); + 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 3f7cd4c7b2..6b72bbaacb 100644 --- a/src/Umbraco.Core/Webhooks/WebhookEventContentBase.cs +++ b/src/Umbraco.Core/Webhooks/WebhookEventContentBase.cs @@ -14,10 +14,10 @@ public abstract class WebhookEventContentBase : WebhookE { protected WebhookEventContentBase( IWebhookFiringService webhookFiringService, - IWebHookService webHookService, + IWebhookService webhookService, IOptionsMonitor webhookSettings, IServerRoleAccessor serverRoleAccessor) - : base(webhookFiringService, webHookService, webhookSettings, serverRoleAccessor) + : base(webhookFiringService, webhookService, webhookSettings, serverRoleAccessor) { } diff --git a/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs b/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs index 49b80144a7..68d668645e 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs @@ -13,15 +13,15 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers; [PluginController(Constants.Web.Mvc.BackOfficeApiArea)] 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, IWebhookPresentationFactory webhookPresentationFactory) + public WebhookController(IWebhookService webhookService, IUmbracoMapper umbracoMapper, WebhookEventCollection webhookEventCollection, IWebhookLogService webhookLogService, IWebhookPresentationFactory webhookPresentationFactory) { - _webHookService = webHookService; + _webhookService = webhookService; _umbracoMapper = umbracoMapper; _webhookEventCollection = webhookEventCollection; _webhookLogService = webhookLogService; @@ -31,7 +31,7 @@ public class WebhookController : UmbracoAuthorizedJsonController [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); IEnumerable webhookViewModels = webhooks.Items.Select(_webhookPresentationFactory.Create); @@ -43,7 +43,7 @@ public class WebhookController : UmbracoAuthorizedJsonController { Webhook updateModel = _umbracoMapper.Map(webhookViewModel)!; - await _webHookService.UpdateAsync(updateModel); + await _webhookService.UpdateAsync(updateModel); return Ok(); } @@ -52,7 +52,7 @@ public class WebhookController : UmbracoAuthorizedJsonController public async Task Create(WebhookViewModel webhookViewModel) { Webhook webhook = _umbracoMapper.Map(webhookViewModel)!; - await _webHookService.CreateAsync(webhook); + await _webhookService.CreateAsync(webhook); return Ok(); } @@ -60,7 +60,7 @@ public class WebhookController : UmbracoAuthorizedJsonController [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); } @@ -68,7 +68,7 @@ public class WebhookController : UmbracoAuthorizedJsonController [HttpDelete] public async Task Delete(Guid key) { - await _webHookService.DeleteAsync(key); + await _webhookService.DeleteAsync(key); return Ok(); } 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 b840facd59..cf85f0df2c 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 @@ -14,7 +14,7 @@ vm.page = {}; vm.webhooks = []; vm.events = []; - vm.webHooksContentTypes = {}; + vm.webhooksContentTypes = {}; vm.webhookEvents = {}; function init() { @@ -88,17 +88,17 @@ function resolveTypeNames(webhook) { 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; } }); }); @@ -176,7 +176,7 @@ .then(result => { vm.webhooks = result; vm.webhookEvents = {}; - vm.webHooksContentTypes = {}; + vm.webhooksContentTypes = {}; vm.webhooks.forEach(webhook => { resolveTypeNames(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..9b2c1f7461 100644 --- a/src/Umbraco.Web.UI.Client/src/views/webhooks/webhooks.html +++ b/src/Umbraco.Web.UI.Client/src/views/webhooks/webhooks.html @@ -36,7 +36,7 @@ {{ vm.webhookEvents[webhook.key] }} {{ webhook.url }} - {{ vm.webHooksContentTypes[webhook.key] }} + {{ vm.webhooksContentTypes[webhook.key] }} GetRequiredService(); + private IWebhookService WebhookService => GetRequiredService(); [Test] [TestCase("https://example.com", Constants.WebhookEvents.Aliases.ContentPublish, "00000000-0000-0000-0000-010000000000")] From b8f5078135c9d77d8842bf8ca9364f5b6326becf Mon Sep 17 00:00:00 2001 From: Ronald Barendse Date: Fri, 10 Nov 2023 09:07:10 +0100 Subject: [PATCH 10/36] Improve logging of invalid reference relations (#15160) * Include automatic relation type aliases from factory * Remove unnessecary distinct and fix SQL parameter overflow issue * Fixed assertions and test distinct aliases * Simplified collection assertions * Improve logging of invalid reference relations --- .../Implement/ContentRepositoryBase.cs | 72 ++++++++++--------- 1 file changed, 40 insertions(+), 32 deletions(-) diff --git a/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs b/src/Umbraco.Infrastructure/Persistence/Repositories/Implement/ContentRepositoryBase.cs index fde1c6ca05..6df4d66ab5 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); } /// From 8f8302358a1ca0847387460f1b83f52ee28309a3 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Fri, 10 Nov 2023 10:17:36 +0100 Subject: [PATCH 11/36] Added notifications to the webhook service (#15174) * Added webhooks to UdiEntityTypes and UdiGetterExtensions. * Renamed IWebhookservice and amended signature of CreateAsync to allow returning of null. Added notifications for webhooks. * Further renames. * Fixed failing unit test. * Removed inadvertently added file. --- src/Umbraco.Core/Constants-UdiEntityType.cs | 2 + .../Extensions/UdiGetterExtensions.cs | 15 +++++++ .../WebhookDeletedNotification .cs | 12 ++++++ .../WebhookDeletingNotification.cs | 17 ++++++++ .../Notifications/WebhookSavedNotification.cs | 17 ++++++++ .../WebhookSavingNotification.cs | 17 ++++++++ src/Umbraco.Core/Services/IWebhookService.cs | 2 +- src/Umbraco.Core/Services/WebhookService.cs | 43 ++++++++++++++++++- src/Umbraco.Core/UdiParser.cs | 3 +- src/Umbraco.Core/Webhooks/WebhookEventBase.cs | 2 +- .../Controllers/WebhookController.cs | 2 +- 11 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 src/Umbraco.Core/Notifications/WebhookDeletedNotification .cs create mode 100644 src/Umbraco.Core/Notifications/WebhookDeletingNotification.cs create mode 100644 src/Umbraco.Core/Notifications/WebhookSavedNotification.cs create mode 100644 src/Umbraco.Core/Notifications/WebhookSavingNotification.cs 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/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/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/Services/IWebhookService.cs b/src/Umbraco.Core/Services/IWebhookService.cs index d38adaa999..38306839ba 100644 --- a/src/Umbraco.Core/Services/IWebhookService.cs +++ b/src/Umbraco.Core/Services/IWebhookService.cs @@ -8,7 +8,7 @@ public interface IWebhookService /// Creates a webhook. /// /// to create. - Task CreateAsync(Webhook webhook); + Task CreateAsync(Webhook webhook); /// /// Updates a webhook. diff --git a/src/Umbraco.Core/Services/WebhookService.cs b/src/Umbraco.Core/Services/WebhookService.cs index e6d5f405b4..40a827c51e 100644 --- a/src/Umbraco.Core/Services/WebhookService.cs +++ b/src/Umbraco.Core/Services/WebhookService.cs @@ -1,4 +1,6 @@ +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; @@ -8,18 +10,33 @@ 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 (scope.Notifications.PublishCancelable(savingNotification)) + { + scope.Complete(); + return null; + } + Webhook created = await _webhookRepository.CreateAsync(webhook); + + scope.Notifications.Publish( + new WebhookSavedNotification(webhook, eventMessages).WithStateFrom(savingNotification)); + scope.Complete(); return created; @@ -37,6 +54,14 @@ public class WebhookService : IWebhookService throw new ArgumentException("Webhook does not exist"); } + EventMessages eventMessages = _eventMessagesFactory.Get(); + var savingNotification = new WebhookSavingNotification(webhook, eventMessages); + if (scope.Notifications.PublishCancelable(savingNotification)) + { + scope.Complete(); + return; + } + currentWebhook.Enabled = webhook.Enabled; currentWebhook.ContentTypeKeys = webhook.ContentTypeKeys; currentWebhook.Events = webhook.Events; @@ -44,6 +69,10 @@ public class WebhookService : IWebhookService currentWebhook.Headers = webhook.Headers; await _webhookRepository.UpdateAsync(currentWebhook); + + scope.Notifications.Publish( + new WebhookSavedNotification(webhook, eventMessages).WithStateFrom(savingNotification)); + scope.Complete(); } @@ -54,7 +83,17 @@ public class WebhookService : IWebhookService Webhook? webhook = await _webhookRepository.GetAsync(key); if (webhook is not null) { + EventMessages eventMessages = _eventMessagesFactory.Get(); + var deletingNotification = new WebhookDeletingNotification(webhook, eventMessages); + if (scope.Notifications.PublishCancelable(deletingNotification)) + { + scope.Complete(); + return; + } + await _webhookRepository.DeleteAsync(webhook); + scope.Notifications.Publish( + new WebhookDeletedNotification(webhook, eventMessages).WithStateFrom(deletingNotification)); } scope.Complete(); 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/Webhooks/WebhookEventBase.cs b/src/Umbraco.Core/Webhooks/WebhookEventBase.cs index de15e23a00..529fe4191b 100644 --- a/src/Umbraco.Core/Webhooks/WebhookEventBase.cs +++ b/src/Umbraco.Core/Webhooks/WebhookEventBase.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.Events; diff --git a/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs b/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs index 68d668645e..5e76d54ef4 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Models; From 31678bb67644ed1653a69bc5416d1ba5f35d1b76 Mon Sep 17 00:00:00 2001 From: Erik-Jan Westendorp Date: Fri, 10 Nov 2023 10:41:31 +0100 Subject: [PATCH 12/36] Translate webhooks keys to Dutch (#15091) --- src/Umbraco.Core/EmbeddedResources/Lang/en.xml | 7 +++++++ src/Umbraco.Core/EmbeddedResources/Lang/nl.xml | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml index 4dc13c1ede..c85b1122cd 100644 --- a/src/Umbraco.Core/EmbeddedResources/Lang/en.xml +++ b/src/Umbraco.Core/EmbeddedResources/Lang/en.xml @@ -1930,6 +1930,13 @@ 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 + Logs + Add Document Type + Add Media Type + Add language ISO code 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 From 81caf2b384c9772e356f0ee4d3480b9ba5a2b536 Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 10 Nov 2023 13:38:08 +0100 Subject: [PATCH 13/36] V14: make v13 login screen work initially with Management API (#15170) * make the login check a bit more robust to be able to handle both old postlogin and new management api response types * v14 only: update the login url to work with the management api (this will still work with the old backoffice to some extent) * Revert "v14 only: update the login url to work with the management api (this will still work with the old backoffice to some extent)" This reverts commit 0639ca80f0ce620b3555b959d5ff10678730acfd. * V14 only: additionally authenticate with the Management API to set the right cookies --- src/Umbraco.Web.UI.Login/src/auth.element.ts | 9 +- .../src/context/auth.context.ts | 25 +- .../src/context/auth.repository.ts | 459 ++++++++++-------- 3 files changed, 272 insertions(+), 221 deletions(-) diff --git a/src/Umbraco.Web.UI.Login/src/auth.element.ts b/src/Umbraco.Web.UI.Login/src/auth.element.ts index 1858bb966d..7bf9c1fa48 100644 --- a/src/Umbraco.Web.UI.Login/src/auth.element.ts +++ b/src/Umbraco.Web.UI.Login/src/auth.element.ts @@ -60,8 +60,6 @@ const createForm = (elements: HTMLElement[]) => { @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/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..75ab8bb496 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,289 @@ 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); + // Additionally authenticate with the Management API + await this.#managementApiLogin(data.username, data.password); - return { - status: response.status, - error: response.ok ? undefined : await this.#getErrorText(response), - }; - } + try { + const text = await response.text(); + if (text) { + responseData.data = JSON.parse(this.#removeAngularJSResponseData(text)); + } + } catch {} - 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, + data: responseData?.data, + twoFactorView: responseData?.twoFactorView, + }; + } catch (error) { + return { + status: 500, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } - return { - status: response.status, - error: response.ok ? undefined : await this.#getErrorText(response), - }; - } + 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); - 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 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 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); + 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 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 : await 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 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); - return { - status: response.status, - data, - }; - } + return { + status: response.status, + error: response.ok ? undefined : await this.#getErrorText(response), + }; + } - return { - status: response.status, - error: response.ok ? undefined : 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 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); + // Check if response contains AngularJS response data + if (response.ok) { + let text = await response.text(); + text = this.#removeAngularJSResponseData(text); + const data = JSON.parse(text); - // 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, + data, + }; + } - return { - status: response.status, - user, - }; - } + return { + status: response.status, + error: response.ok ? undefined : this.#getErrorText(response), + }; + } - return { - status: response.status, - error: this.#getErrorText(response), - }; - } + 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); - public async getMfaProviders(): Promise { - const request = new Request('backoffice/umbracoapi/authentication/Get2faProviders', { - method: 'GET', - 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 user = JSON.parse(text); - // 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, + user, + }; + } - return { - status: response.status, - providers, - }; - } + return { + status: response.status, + error: this.#getErrorText(response), + }; + } - return { - status: response.status, - error: await this.#getErrorText(response), - 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); - 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', - }, - }); + // Check if response contains AngularJS response data + if (response.ok) { + let text = await response.text(); + text = this.#removeAngularJSResponseData(text); + const providers = JSON.parse(text); - const response = await fetch(request); + return { + status: response.status, + providers, + }; + } + + 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; + } + + async #managementApiLogin(username: string, password: string) { + try { + const authURLManagementApi = 'management/api/v1/security/back-office/login'; + const requestManagementApi = new Request(authURLManagementApi, { + method: 'POST', + body: JSON.stringify({ + username, + password, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + + return await fetch(requestManagementApi); + } catch (error) { + console.error('Failed to authenticate with the Management API:', error); + } + } } From f4cb7e71fa5b35de6d744bf73e8b5c48d697627f Mon Sep 17 00:00:00 2001 From: Jacob Overgaard <752371+iOvergaard@users.noreply.github.com> Date: Fri, 10 Nov 2023 13:45:00 +0100 Subject: [PATCH 14/36] Cherry-picked c03df2b74ed120246768d1d8803a99612e4386f3 into v13/dev --- .../src/context/auth.repository.ts | 23 ------------------- 1 file changed, 23 deletions(-) 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 75ab8bb496..24c1cfe015 100644 --- a/src/Umbraco.Web.UI.Login/src/context/auth.repository.ts +++ b/src/Umbraco.Web.UI.Login/src/context/auth.repository.ts @@ -34,9 +34,6 @@ export class UmbAuthRepository { return responseData; } - // Additionally authenticate with the Management API - await this.#managementApiLogin(data.username, data.password); - try { const text = await response.text(); if (text) { @@ -275,24 +272,4 @@ export class UmbAuthRepository { return text; } - - async #managementApiLogin(username: string, password: string) { - try { - const authURLManagementApi = 'management/api/v1/security/back-office/login'; - const requestManagementApi = new Request(authURLManagementApi, { - method: 'POST', - body: JSON.stringify({ - username, - password, - }), - headers: { - 'Content-Type': 'application/json', - }, - }); - - return await fetch(requestManagementApi); - } catch (error) { - console.error('Failed to authenticate with the Management API:', error); - } - } } From 7c877431912178a0938296679f638568a8cbbeef Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Fri, 10 Nov 2023 14:14:32 +0100 Subject: [PATCH 15/36] Add Auth attribute to controller (#15178) --- src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs b/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs index 5e76d54ef4..afa39ff623 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs @@ -1,3 +1,4 @@ +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Mapping; @@ -6,11 +7,13 @@ using Umbraco.Cms.Core.Services; 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; From e48e7ff1464e66d0d5f1f46020aecf3ca4528849 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Mon, 13 Nov 2023 09:11:11 +0100 Subject: [PATCH 16/36] Add [SpecialType] attributes to request & response (#15103) Co-authored-by: Zeegaan --- src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs index a8606c7391..7637ec4dc3 100644 --- a/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs +++ b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs @@ -42,18 +42,22 @@ internal class WebhookLogDto 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; } From 1d43a67934323e89ce5a836c2bf57967fd881df6 Mon Sep 17 00:00:00 2001 From: Bjarne Fyrstenborg Date: Mon, 13 Nov 2023 10:14:40 +0100 Subject: [PATCH 17/36] Add edit page for webhook (#15175) * Add edit page for webhook * Remove function again * Remove old edit in overlay * Update language keys * Update GetByKey to get model as camel case properties * Localize "select event" * Localize "create header" * Don't show input field in header for now * Add generic class to limit property editor width * Remove loading check since already checked when loading entire editor view * Add empty message * Update col span * Set edit name * Map id property * Return webhook model after update * Translate speech bubble text * Resolve content types * Only push if not already exists * Check webhook headers * Set webhook enabled by default --- .../EmbeddedResources/Lang/da.xml | 10 +- .../EmbeddedResources/Lang/en.xml | 11 +- .../EmbeddedResources/Lang/en_us.xml | 11 +- .../Controllers/WebhookController.cs | 11 +- .../Services/WebhookPresentationFactory.cs | 18 +- .../Models/WebhookViewModel.cs | 5 +- .../src/less/property-editors.less | 6 +- .../src/views/languages/edit.controller.js | 2 +- .../views/languages/overview.controller.js | 1 + .../src/views/webhooks/edit.controller.js | 330 ++++++++++++++++++ .../src/views/webhooks/edit.html | 206 +++++++++++ .../webhooks/overlays/edit.controller.js | 146 -------- .../src/views/webhooks/overlays/edit.html | 159 --------- .../src/views/webhooks/webhooks.controller.js | 89 +---- .../src/views/webhooks/webhooks.html | 4 +- 15 files changed, 603 insertions(+), 406 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/views/webhooks/edit.controller.js create mode 100644 src/Umbraco.Web.UI.Client/src/views/webhooks/edit.html delete mode 100644 src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.controller.js delete mode 100644 src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.html 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 c85b1122cd..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 @@ -1933,9 +1936,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/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.Web.BackOffice/Controllers/WebhookController.cs b/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs index afa39ff623..9db46330ed 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs @@ -44,20 +44,21 @@ 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); + await _webhookService.UpdateAsync(webhook); - return Ok(); + return Ok(_webhookPresentationFactory.Create(webhook)); } [HttpPost] public async Task Create(WebhookViewModel webhookViewModel) { Webhook webhook = _umbracoMapper.Map(webhookViewModel)!; + await _webhookService.CreateAsync(webhook); - return Ok(); + return Ok(_webhookPresentationFactory.Create(webhook)); } [HttpGet] @@ -65,7 +66,7 @@ public class WebhookController : UmbracoAuthorizedJsonController { Webhook? webhook = await _webhookService.GetAsync(key); - return webhook is null ? NotFound() : Ok(webhook); + return webhook is null ? NotFound() : Ok(_webhookPresentationFactory.Create(webhook)); } [HttpDelete] diff --git a/src/Umbraco.Web.BackOffice/Services/WebhookPresentationFactory.cs b/src/Umbraco.Web.BackOffice/Services/WebhookPresentationFactory.cs index 57ef7e5f2a..ef4e052596 100644 --- a/src/Umbraco.Web.BackOffice/Services/WebhookPresentationFactory.cs +++ b/src/Umbraco.Web.BackOffice/Services/WebhookPresentationFactory.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Core; +using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Webhooks; using Umbraco.Cms.Web.Common.Models; @@ -14,12 +14,15 @@ internal class WebhookPresentationFactory : IWebhookPresentationFactory public WebhookViewModel Create(Webhook webhook) { var target = new WebhookViewModel - { - ContentTypeKeys = webhook.ContentTypeKeys, Events = webhook.Events.Select(Create).ToArray(), Url = webhook.Url, - Enabled = webhook.Enabled, - Key = webhook.Key, - Headers = webhook.Headers, - }; + { + 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; } @@ -27,6 +30,7 @@ internal class WebhookPresentationFactory : IWebhookPresentationFactory private WebhookEventViewModel Create(string alias) { IWebhookEvent? webhookEvent = _webhookEventCollection.FirstOrDefault(x => x.Alias == alias); + return new WebhookEventViewModel { EventName = webhookEvent?.EventName ?? alias, diff --git a/src/Umbraco.Web.Common/Models/WebhookViewModel.cs b/src/Umbraco.Web.Common/Models/WebhookViewModel.cs index 9030ff050c..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; } 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/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/overlays/edit.controller.js b/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.controller.js deleted file mode 100644 index 6f8a17ceb8..0000000000 --- a/src/Umbraco.Web.UI.Client/src/views/webhooks/overlays/edit.controller.js +++ /dev/null @@ -1,146 +0,0 @@ -(function () { - "use strict"; - - function EditController($scope, editorService, contentTypeResource, mediaTypeResource, memberTypeResource) { - - 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 eventType = $scope.model.webhook ? $scope.model.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); - $scope.model.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 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, eventType) { - let resource; - switch (eventType.toCamelCase()) { - case "content": - resource = contentTypeResource; - break; - case "media": - resource = mediaTypeResource; - break; - case "member": - resource = memberTypeResource; - break; - default: - return; - } - - $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 4bc61fec98..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 cf85f0df2c..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 () { +(function () { "use strict"; - function WebhookController($q, $timeout, $routeParams, webhooksResource, navigationService, notificationsService, editorService, overlayService, contentTypeResource, mediaTypeResource, memberTypeResource) { + 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; @@ -71,20 +72,6 @@ return resource; } - function getEntities(webhook) { - let resource = determineResource(webhook.events[0].eventType.toLowerCase()); - let entities = []; - - webhook.contentTypeKeys.forEach(key => { - resource.getById(key) - .then(data => { - entities.push(data); - }); - }); - - return entities; - } - function resolveTypeNames(webhook) { let resource = determineResource(webhook.events[0].eventType.toLowerCase()); @@ -104,70 +91,20 @@ }); } - 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(){ @@ -185,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 9b2c1f7461..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 @@ - + Date: Thu, 2 Nov 2023 12:15:14 +0000 Subject: [PATCH 18/36] Refactor hostedServices into background jobs (#14291) * Refactor jobs from HostedServices into BackgroundJobs * Clean up generics and DI setup * Add RecurringBackgroundJob Unit Tests * Add ServiceCollection helper * Add Obsolete attributes * Add Notification Classes * Add UnitTests for RecurringBackgroundJob HostedService * Add NotificationEvents * Add state to notifications * Update UnitTests * Add Obsolete Attributes to old hosted service classes * Updated xmldoc in IRecurringBackgroundJob.cs * Update Obsolete attribute messages to indicate classes will be removed in Umbraco 14 (cherry picked from commit c30ffa9ac3ae9c12508242855c63208567c55eb9) --- .../BackgroundJobs/DelayCalculator.cs | 67 ++++++ .../BackgroundJobs/IRecurringBackgroundJob.cs | 28 +++ .../Jobs/ContentVersionCleanupJob.cs | 66 ++++++ .../Jobs/HealthCheckNotifierJob.cs | 116 ++++++++++ .../BackgroundJobs/Jobs/KeepAliveJob.cs | 90 ++++++++ .../BackgroundJobs/Jobs/LogScrubberJob.cs | 73 ++++++ .../BackgroundJobs/Jobs/ReportSiteJob.cs | 92 ++++++++ .../Jobs/ScheduledPublishingJob.cs | 105 +++++++++ .../InstructionProcessJob.cs | 59 +++++ .../Jobs/ServerRegistration/TouchServerJob.cs | 102 +++++++++ .../BackgroundJobs/Jobs/TempFileCleanupJob.cs | 99 +++++++++ .../RecurringBackgroundJobHostedService.cs | 127 +++++++++++ ...curringBackgroundJobHostedServiceRunner.cs | 81 +++++++ .../Extensions/ServiceCollectionExtensions.cs | 31 +++ .../HostedServices/ContentVersionCleanup.cs | 1 + .../HostedServices/HealthCheckNotifier.cs | 1 + .../HostedServices/KeepAlive.cs | 1 + .../HostedServices/LogScrubber.cs | 1 + .../RecurringHostedServiceBase.cs | 4 +- .../HostedServices/ReportSiteTask.cs | 1 + .../HostedServices/ScheduledPublishing.cs | 1 + .../InstructionProcessTask.cs | 1 + .../ServerRegistration/TouchServerTask.cs | 1 + .../HostedServices/TempFileCleanup.cs | 1 + ...urringBackgroundJobExecutedNotification.cs | 12 + ...rringBackgroundJobExecutingNotification.cs | 12 + ...ecurringBackgroundJobFailedNotification.cs | 12 + ...curringBackgroundJobIgnoredNotification.cs | 12 + .../RecurringBackgroundJobNotification.cs | 12 + ...curringBackgroundJobStartedNotification.cs | 12 + ...urringBackgroundJobStartingNotification.cs | 12 + ...curringBackgroundJobStoppedNotification.cs | 12 + ...urringBackgroundJobStoppingNotification.cs | 12 + .../UmbracoBuilderExtensions.cs | 2 +- .../UmbracoBuilderExtensions.cs | 35 +++ .../Jobs/HealthCheckNotifierJobTests.cs | 139 ++++++++++++ .../BackgroundJobs/Jobs/KeepAliveJobTests.cs | 94 ++++++++ .../Jobs/LogScrubberJobTests.cs | 72 ++++++ .../Jobs/ScheduledPublishingJobTests.cs | 92 ++++++++ .../InstructionProcessJobTests.cs | 51 +++++ .../ServerRegistration/TouchServerJobTests.cs | 95 ++++++++ .../Jobs/TempFileCleanupJobTests.cs | 50 +++++ ...ecurringBackgroundJobHostedServiceTests.cs | 208 ++++++++++++++++++ .../HealthCheckNotifierTests.cs | 1 + .../HostedServices/KeepAliveTests.cs | 1 + .../HostedServices/LogScrubberTests.cs | 1 + .../ScheduledPublishingTests.cs | 1 + .../InstructionProcessTaskTests.cs | 1 + .../TouchServerTaskTests.cs | 1 + .../HostedServices/TempFileCleanupTests.cs | 1 + 50 files changed, 2099 insertions(+), 3 deletions(-) create mode 100644 src/Umbraco.Infrastructure/BackgroundJobs/DelayCalculator.cs create mode 100644 src/Umbraco.Infrastructure/BackgroundJobs/IRecurringBackgroundJob.cs create mode 100644 src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ContentVersionCleanupJob.cs create mode 100644 src/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJob.cs create mode 100644 src/Umbraco.Infrastructure/BackgroundJobs/Jobs/KeepAliveJob.cs create mode 100644 src/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJob.cs create mode 100644 src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ReportSiteJob.cs create mode 100644 src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJob.cs create mode 100644 src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJob.cs create mode 100644 src/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJob.cs create mode 100644 src/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJob.cs create mode 100644 src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedService.cs create mode 100644 src/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceRunner.cs create mode 100644 src/Umbraco.Infrastructure/Extensions/ServiceCollectionExtensions.cs create mode 100644 src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobExecutedNotification.cs create mode 100644 src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobExecutingNotification.cs create mode 100644 src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobFailedNotification.cs create mode 100644 src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobIgnoredNotification.cs create mode 100644 src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobNotification.cs create mode 100644 src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStartedNotification.cs create mode 100644 src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStartingNotification.cs create mode 100644 src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStoppedNotification.cs create mode 100644 src/Umbraco.Infrastructure/Notifications/RecurringBackgroundJobStoppingNotification.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/HealthCheckNotifierJobTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/KeepAliveJobTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/LogScrubberJobTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ScheduledPublishingJobTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/InstructionProcessJobTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/ServerRegistration/TouchServerJobTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/Jobs/TempFileCleanupJobTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/BackgroundJobs/RecurringBackgroundJobHostedServiceTests.cs 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/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/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 7184aaf16e..77800ae7d6 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/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.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs index dfa22b06e8..7c23ce19da 100644 --- a/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.BackOffice/DependencyInjection/UmbracoBuilderExtensions.cs @@ -46,7 +46,7 @@ public static partial class UmbracoBuilderExtensions .AddMvcAndRazor(configureMvc) .AddWebServer() .AddPreviewSupport() - .AddHostedServices() + .AddRecurringBackgroundJobs() .AddNuCache() .AddDistributedCache() .TryAddModelsBuilderDashboard() diff --git a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 2dd828f9c2..71704f4edc 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; @@ -38,6 +39,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; @@ -176,6 +180,7 @@ public static partial class UmbracoBuilderExtensions /// /// Add Umbraco hosted services /// + [Obsolete("Use AddRecurringBackgroundJobs instead")] public static IUmbracoBuilder AddHostedServices(this IUmbracoBuilder builder) { builder.Services.AddHostedService(); @@ -191,6 +196,36 @@ 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(provider => + new ReportSiteJob( + provider.GetRequiredService>(), + provider.GetRequiredService())); + + + builder.Services.AddHostedService(); + builder.Services.AddSingleton(RecurringBackgroundJobHostedService.CreateHostedServiceFactory); + builder.Services.AddHostedService(); + + return builder; } 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; From 16a27a4c520f3879f5158206928b5c8f3fb8bd5c Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Mon, 13 Nov 2023 13:33:02 +0100 Subject: [PATCH 19/36] use header value instead if present to set culture on request (#15168) --- src/Umbraco.Web.Common/Extensions/HttpRequestExtensions.cs | 3 +++ src/Umbraco.Web.Common/Middleware/UmbracoRequestMiddleware.cs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) 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); From e5de8bf56b7f9f350c9ee25b79519007fa47e153 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Mon, 13 Nov 2023 14:59:52 +0100 Subject: [PATCH 20/36] Updated nuget packages (#15192) --- src/JsonSchema/JsonSchema.csproj | 2 +- .../Umbraco.Cms.Persistence.Sqlite.csproj | 2 +- src/Umbraco.Core/Umbraco.Core.csproj | 4 ++-- .../Enrichers/ThreadAbortExceptionEnricher.cs | 2 +- .../Umbraco.Infrastructure.csproj | 22 +++++++++---------- .../Umbraco.PublishedCache.NuCache.csproj | 6 ++--- .../Umbraco.Web.BackOffice.csproj | 2 +- .../Umbraco.Web.Common.csproj | 8 +++---- .../Umbraco.Tests.Common.csproj | 2 +- .../Umbraco.Tests.Integration.csproj | 6 ++--- .../Umbraco.Tests.UnitTests.csproj | 6 ++--- 11 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/JsonSchema/JsonSchema.csproj b/src/JsonSchema/JsonSchema.csproj index 967a3c7aa1..5c2ba04f58 100644 --- a/src/JsonSchema/JsonSchema.csproj +++ b/src/JsonSchema/JsonSchema.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj b/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj index cd88791019..10bb499c7d 100644 --- a/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj +++ b/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj @@ -5,7 +5,7 @@ - + diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 97ad56596d..ef23b4fc00 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -8,10 +8,10 @@ - + - + diff --git a/src/Umbraco.Infrastructure/Logging/Serilog/Enrichers/ThreadAbortExceptionEnricher.cs b/src/Umbraco.Infrastructure/Logging/Serilog/Enrichers/ThreadAbortExceptionEnricher.cs index 45495de9e8..f773fe248f 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/Umbraco.Infrastructure.csproj b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj index c72c2022c4..9f46b4f4fb 100644 --- a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj +++ b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj @@ -12,34 +12,34 @@ - - + + - + - + - - + + - + - + - + - + - + diff --git a/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj b/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj index f0e07283fa..725715b4dc 100644 --- a/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj +++ b/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj @@ -8,9 +8,9 @@ - - - + + + diff --git a/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj b/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj index 952f4fbbc5..32598379b4 100644 --- a/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj +++ b/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj @@ -12,7 +12,7 @@ - + diff --git a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj index 8f7ea5f935..5ffee1aa11 100644 --- a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj +++ b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj @@ -12,13 +12,13 @@ - - + + - - + + diff --git a/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj b/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj index 82cf5d9c32..24c7fcdab2 100644 --- a/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj +++ b/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj @@ -11,7 +11,7 @@ - + diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 48140b64dc..373d273786 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -11,10 +11,10 @@ - - + + - + diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj index 8b6085bf6d..9f28c725ae 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj @@ -9,9 +9,9 @@ - - - + + + From 46df3a05a7b63524dd90721684d9b7c9589e4b90 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Mon, 13 Nov 2023 15:49:02 +0100 Subject: [PATCH 21/36] V13: request queueing (#15176) * Implement persistence * Start implementing repository * Implement repository * Implement request service * Dont run WebhookFiring if not in runtime mode run * Refactor repository and service to have full CRUD * add tests for Request service * Implement WebhookRequest lock * Register hosted service * Add try catch when firing HttpRequest * Add migration * Refactor to use renamed IWebhookService * Refactor tests too * Add setting to configure webhook firing period * Update docs * Review fixes * Add column renaming migration * Remove unused service * run request in parralel * Refactor to fire parallel in background --------- Co-authored-by: Zeegaan Co-authored-by: Bjarke Berg --- .../Configuration/Models/WebhookSettings.cs | 7 + .../DependencyInjection/UmbracoBuilder.cs | 5 +- src/Umbraco.Core/Models/WebhookRequest.cs | 14 ++ .../Persistence/Constants-DatabaseSchema.cs | 1 + .../Persistence/Constants-Locks.cs | 5 + .../Repositories/IWebhookRequestRepository.cs | 33 +++++ .../Services/IWebhookRequestService.cs | 35 +++++ .../Services/WebhookRequestService.cs | 63 +++++++++ .../BackgroundJobs/Jobs/WebhookFiring.cs | 124 ++++++++++++++++++ .../UmbracoBuilder.Repositories.cs | 1 + .../Migrations/Install/DatabaseDataCreator.cs | 38 ++---- .../Install/DatabaseSchemaCreator.cs | 1 + .../Migrations/Upgrade/UmbracoPlan.cs | 2 + .../Upgrade/V_13_0_0/AddWebhookRequest.cs | 35 +++++ .../Upgrade/V_13_0_0/RenameWebhookIdToKey.cs | 27 ++++ .../Persistence/Dtos/WebhookLogDto.cs | 2 +- .../Persistence/Dtos/WebhookRequestDto.cs | 28 ++++ .../Factories/WebhookRequestFactory.cs | 27 ++++ .../Implement/WebhookRequestRepository.cs | 65 +++++++++ .../Implement/WebhookFiringService.cs | 68 +--------- .../ExamineManagementController.cs | 3 + .../RedirectUrlManagementController.cs | 3 + .../UmbracoBuilderExtensions.cs | 3 +- .../Services/WebhookRequestServiceTests.cs | 67 ++++++++++ 24 files changed, 565 insertions(+), 92 deletions(-) create mode 100644 src/Umbraco.Core/Models/WebhookRequest.cs create mode 100644 src/Umbraco.Core/Persistence/Repositories/IWebhookRequestRepository.cs create mode 100644 src/Umbraco.Core/Services/IWebhookRequestService.cs create mode 100644 src/Umbraco.Core/Services/WebhookRequestService.cs create mode 100644 src/Umbraco.Infrastructure/BackgroundJobs/Jobs/WebhookFiring.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/AddWebhookRequest.cs create mode 100644 src/Umbraco.Infrastructure/Migrations/Upgrade/V_13_0_0/RenameWebhookIdToKey.cs create mode 100644 src/Umbraco.Infrastructure/Persistence/Dtos/WebhookRequestDto.cs create mode 100644 src/Umbraco.Infrastructure/Persistence/Factories/WebhookRequestFactory.cs create mode 100644 src/Umbraco.Infrastructure/Persistence/Repositories/Implement/WebhookRequestRepository.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookRequestServiceTests.cs 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/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 354ef7a40c..c8ef1fbff7 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -35,13 +35,11 @@ using Umbraco.Cms.Core.Runtime; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Core.Snippets; using Umbraco.Cms.Core.DynamicRoot; 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 @@ -332,9 +330,12 @@ namespace Umbraco.Cms.Core.DependencyInjection // Register filestream security analyzers Services.AddUnique(); Services.AddUnique(); + + // Register Webhook services Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); } } } 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/Persistence/Constants-DatabaseSchema.cs b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs index cdc5af450a..631e99df6e 100644 --- a/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs +++ b/src/Umbraco.Core/Persistence/Constants-DatabaseSchema.cs @@ -91,6 +91,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/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/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/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.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/DependencyInjection/UmbracoBuilder.Repositories.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs index 2e4832cff0..8c9f4b1193 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.Repositories.cs @@ -73,6 +73,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddUnique(); builder.Services.AddUnique(); builder.Services.AddUnique(); + builder.Services.AddUnique(); return builder; } diff --git a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseDataCreator.cs index a3f685f0ae..2d17d42f83 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 9a944e4a90..fdd8a07256 100644 --- a/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs +++ b/src/Umbraco.Infrastructure/Migrations/Install/DatabaseSchemaCreator.cs @@ -88,6 +88,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 3a4e715228..e543c61279 100644 --- a/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs +++ b/src/Umbraco.Infrastructure/Migrations/Upgrade/UmbracoPlan.cs @@ -98,5 +98,7 @@ 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}"); } } 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/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/Persistence/Dtos/WebhookLogDto.cs b/src/Umbraco.Infrastructure/Persistence/Dtos/WebhookLogDto.cs index a9588ecb7d..3f3e5623b7 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")] 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/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/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/Services/Implement/WebhookFiringService.cs b/src/Umbraco.Infrastructure/Services/Implement/WebhookFiringService.cs index 67b55c913e..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 eventAlias, object? payload, CancellationToken cancellationToken) - { - for (var retry = 0; retry < _webhookSettings.MaximumRetries; retry++) - { - HttpResponseMessage response = await SendRequestAsync(webhook, eventAlias, payload, retry, cancellationToken); - - if (response.IsSuccessStatusCode) - { - return; - } - } - } - - private async Task SendRequestAsync(Webhook webhook, string eventAlias, 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", eventAlias); - - 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(eventAlias, 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.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.Common/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs index 71704f4edc..a08f712b37 100644 --- a/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Common/DependencyInjection/UmbracoBuilderExtensions.cs @@ -215,6 +215,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddRecurringBackgroundJob(); builder.Services.AddRecurringBackgroundJob(); builder.Services.AddRecurringBackgroundJob(); + builder.Services.AddRecurringBackgroundJob(); builder.Services.AddRecurringBackgroundJob(provider => new ReportSiteJob( provider.GetRequiredService>(), @@ -224,7 +225,7 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddHostedService(); builder.Services.AddSingleton(RecurringBackgroundJobHostedService.CreateHostedServiceFactory); builder.Services.AddHostedService(); - + return builder; } 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..3c41aa5ab2 --- /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.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.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.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); + }); + } +} From abfa07367fcefc71c6a23ca83827039dfc961669 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Mon, 13 Nov 2023 18:06:41 +0100 Subject: [PATCH 22/36] Bump version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 3c0aeb9872..364e6e8834 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "10.8.0-rc", + "version": "10.9.0-rc", "assemblyVersion": { "precision": "build" }, From 0e8c92a6e7483f6944cc0e5566d66232cd15d671 Mon Sep 17 00:00:00 2001 From: Nikolaj Geisle <70372949+Zeegaan@users.noreply.github.com> Date: Mon, 13 Nov 2023 18:36:05 +0100 Subject: [PATCH 23/36] V13: Refactor webhookservice to attempt pattern (#15180) * Refactor IWebhookService to attempt pattern * Remove unwanted file * Fix up tests * Fix after merge --------- Co-authored-by: Zeegaan --- src/Umbraco.Core/Services/IWebhookService.cs | 7 ++- .../OperationStatus/WebhookOperationStatus.cs | 8 +++ src/Umbraco.Core/Services/WebhookService.cs | 55 ++++++++++--------- .../Controllers/WebhookController.cs | 32 +++++++---- .../Services/WebhookServiceTests.cs | 20 +++---- 5 files changed, 73 insertions(+), 49 deletions(-) create mode 100644 src/Umbraco.Core/Services/OperationStatus/WebhookOperationStatus.cs diff --git a/src/Umbraco.Core/Services/IWebhookService.cs b/src/Umbraco.Core/Services/IWebhookService.cs index 38306839ba..657f29df59 100644 --- a/src/Umbraco.Core/Services/IWebhookService.cs +++ b/src/Umbraco.Core/Services/IWebhookService.cs @@ -1,4 +1,5 @@ using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services.OperationStatus; namespace Umbraco.Cms.Core.Services; @@ -8,19 +9,19 @@ 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. 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/WebhookService.cs b/src/Umbraco.Core/Services/WebhookService.cs index 40a827c51e..1f707606b5 100644 --- a/src/Umbraco.Core/Services/WebhookService.cs +++ b/src/Umbraco.Core/Services/WebhookService.cs @@ -3,6 +3,7 @@ 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; @@ -20,30 +21,29 @@ public class WebhookService : IWebhookService } /// - 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 (scope.Notifications.PublishCancelable(savingNotification)) + if (await scope.Notifications.PublishCancelableAsync(savingNotification)) { scope.Complete(); - return null; + return Attempt.FailWithStatus(WebhookOperationStatus.CancelledByNotification, webhook); } Webhook created = await _webhookRepository.CreateAsync(webhook); - scope.Notifications.Publish( - new WebhookSavedNotification(webhook, eventMessages).WithStateFrom(savingNotification)); + 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(); @@ -51,15 +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 (scope.Notifications.PublishCancelable(savingNotification)) + if (await scope.Notifications.PublishCancelableAsync(savingNotification)) { scope.Complete(); - return; + return Attempt.FailWithStatus(WebhookOperationStatus.CancelledByNotification, webhook); } currentWebhook.Enabled = webhook.Enabled; @@ -70,33 +71,37 @@ public class WebhookService : IWebhookService await _webhookRepository.UpdateAsync(currentWebhook); - scope.Notifications.Publish( - new WebhookSavedNotification(webhook, eventMessages).WithStateFrom(savingNotification)); + 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) { - EventMessages eventMessages = _eventMessagesFactory.Get(); - var deletingNotification = new WebhookDeletingNotification(webhook, eventMessages); - if (scope.Notifications.PublishCancelable(deletingNotification)) - { - scope.Complete(); - return; - } - - await _webhookRepository.DeleteAsync(webhook); - scope.Notifications.Publish( - new WebhookDeletedNotification(webhook, eventMessages).WithStateFrom(deletingNotification)); + 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); } /// diff --git a/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs b/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs index 9db46330ed..61f583d8eb 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/WebhookController.cs @@ -1,9 +1,12 @@ -using Microsoft.AspNetCore.Authorization; +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; @@ -46,19 +49,16 @@ public class WebhookController : UmbracoAuthorizedJsonController { Webhook webhook = _umbracoMapper.Map(webhookViewModel)!; - await _webhookService.UpdateAsync(webhook); - - return Ok(_webhookPresentationFactory.Create(webhook)); + 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(_webhookPresentationFactory.Create(webhook)); + Attempt result = await _webhookService.CreateAsync(webhook); + return result.Success ? Ok(_webhookPresentationFactory.Create(webhook)) : WebhookOperationStatusResult(result.Status); } [HttpGet] @@ -72,9 +72,8 @@ public class WebhookController : UmbracoAuthorizedJsonController [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] @@ -94,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/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookServiceTests.cs index 4290b7fb06..53368593c1 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookServiceTests.cs @@ -22,7 +22,7 @@ public class WebhookServiceTests : UmbracoIntegrationTest 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(() => { @@ -45,9 +45,9 @@ public class WebhookServiceTests : UmbracoIntegrationTest 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)); }); } @@ -60,11 +60,11 @@ public class WebhookServiceTests : UmbracoIntegrationTest 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); } @@ -72,7 +72,7 @@ public class WebhookServiceTests : UmbracoIntegrationTest public async Task Can_Create_With_No_EntityKeys() { var createdWebhook = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.Aliases.ContentPublish })); - var webhook = await WebhookService.GetAsync(createdWebhook.Key); + var webhook = await WebhookService.GetAsync(createdWebhook.Result.Key); Assert.IsNotNull(webhook); Assert.IsEmpty(webhook.ContentTypeKeys); @@ -82,10 +82,10 @@ public class WebhookServiceTests : UmbracoIntegrationTest public async Task Can_Update() { var createdWebhook = await WebhookService.CreateAsync(new Webhook("https://example.com", events: new[] { Constants.WebhookEvents.Aliases.ContentPublish })); - createdWebhook.Events = new[] { Constants.WebhookEvents.Aliases.ContentDelete }; - await WebhookService.UpdateAsync(createdWebhook); + 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.Aliases.ContentDelete)); From 74cfd9c9a0e0c2bc3e16accb4ccd5890c89793bf Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Mon, 13 Nov 2023 18:38:15 +0100 Subject: [PATCH 24/36] Updated nuget packages --- .../Umbraco.Cms.ManagementApi.csproj | 2 +- .../Umbraco.Cms.Persistence.Sqlite.csproj | 2 +- src/Umbraco.Core/Umbraco.Core.csproj | 6 +++--- src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj | 8 ++++---- src/Umbraco.Web.Common/Umbraco.Web.Common.csproj | 8 ++++---- tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj | 4 ++-- .../Umbraco.Tests.Integration.csproj | 2 +- .../Umbraco.Tests.UnitTests.csproj | 2 +- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/Umbraco.Cms.ManagementApi/Umbraco.Cms.ManagementApi.csproj b/src/Umbraco.Cms.ManagementApi/Umbraco.Cms.ManagementApi.csproj index 47612499b5..edcfe8545a 100644 --- a/src/Umbraco.Cms.ManagementApi/Umbraco.Cms.ManagementApi.csproj +++ b/src/Umbraco.Cms.ManagementApi/Umbraco.Cms.ManagementApi.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj b/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj index d9f9ac5123..912f809ec0 100644 --- a/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj +++ b/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj @@ -5,7 +5,7 @@ - + diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 86bf11d4ef..6fc9e3c1e8 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -8,12 +8,12 @@ - + - + - + diff --git a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj index 9eba11ea40..edeefac347 100644 --- a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj +++ b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj @@ -12,16 +12,16 @@ - + - + - + @@ -39,7 +39,7 @@ - + diff --git a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj index d774577bbf..12238f8447 100644 --- a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj +++ b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj @@ -12,13 +12,13 @@ - - - + + + - + diff --git a/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj b/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj index b73e08a1ac..067d2c4058 100644 --- a/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj +++ b/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj @@ -8,10 +8,10 @@ - + - + diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 781e50d0ed..2fee674a69 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.Tests.UnitTests.csproj b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj index 109652b73d..a5dcc4b299 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj @@ -7,7 +7,7 @@ - + From fee9bf12c01bc513fe6235b3556fec5d2fe5d794 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Mon, 13 Nov 2023 18:56:57 +0100 Subject: [PATCH 25/36] Updated nuget packages --- .../Umbraco.Cms.Imaging.ImageSharp.csproj | 2 -- .../Umbraco.Cms.Persistence.EFCore.SqlServer.csproj | 2 +- .../Umbraco.Cms.Persistence.EFCore.Sqlite.csproj | 2 +- .../Umbraco.Cms.Persistence.EFCore.csproj | 4 ++-- .../Umbraco.Cms.Persistence.Sqlite.csproj | 1 - src/Umbraco.Core/Umbraco.Core.csproj | 4 ---- .../Umbraco.Infrastructure.csproj | 12 +----------- .../Umbraco.PublishedCache.NuCache.csproj | 5 +---- .../Umbraco.Web.BackOffice.csproj | 2 -- src/Umbraco.Web.Common/Umbraco.Web.Common.csproj | 3 --- src/Umbraco.Web.UI/Umbraco.Web.UI.csproj | 2 +- .../Umbraco.Tests.Common/Umbraco.Tests.Common.csproj | 4 ---- .../Umbraco.Tests.Integration.csproj | 2 -- .../Umbraco.Tests.UnitTests.csproj | 2 -- 14 files changed, 7 insertions(+), 40 deletions(-) diff --git a/src/Umbraco.Cms.Imaging.ImageSharp/Umbraco.Cms.Imaging.ImageSharp.csproj b/src/Umbraco.Cms.Imaging.ImageSharp/Umbraco.Cms.Imaging.ImageSharp.csproj index 3baebde370..c34595576f 100644 --- a/src/Umbraco.Cms.Imaging.ImageSharp/Umbraco.Cms.Imaging.ImageSharp.csproj +++ b/src/Umbraco.Cms.Imaging.ImageSharp/Umbraco.Cms.Imaging.ImageSharp.csproj @@ -7,8 +7,6 @@ - - diff --git a/src/Umbraco.Cms.Persistence.EFCore.SqlServer/Umbraco.Cms.Persistence.EFCore.SqlServer.csproj b/src/Umbraco.Cms.Persistence.EFCore.SqlServer/Umbraco.Cms.Persistence.EFCore.SqlServer.csproj index f710397004..eec419d34e 100644 --- a/src/Umbraco.Cms.Persistence.EFCore.SqlServer/Umbraco.Cms.Persistence.EFCore.SqlServer.csproj +++ b/src/Umbraco.Cms.Persistence.EFCore.SqlServer/Umbraco.Cms.Persistence.EFCore.SqlServer.csproj @@ -7,7 +7,7 @@ - + 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 6087a86746..d12b9affab 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 @@ -7,7 +7,7 @@ - + diff --git a/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj b/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj index afeab8aa89..7b2b4b17f3 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj +++ b/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj @@ -5,8 +5,8 @@ - - + + diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj b/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj index 7c7d86456a..912f809ec0 100644 --- a/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj +++ b/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj @@ -5,7 +5,6 @@ - diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 66bae14e2c..115db79161 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -8,10 +8,6 @@ - - - - diff --git a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj index c1020d76fd..daf2f7e375 100644 --- a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj +++ b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj @@ -12,32 +12,23 @@ - - - - - - - - - - + @@ -49,7 +40,6 @@ - diff --git a/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj b/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj index 9db9e0e516..a61eba6c79 100644 --- a/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj +++ b/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj @@ -7,11 +7,8 @@ - - - - + diff --git a/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj b/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj index beaf8aaf8c..a64d0d2408 100644 --- a/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj +++ b/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj @@ -13,8 +13,6 @@ - - diff --git a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj index daf183a657..f6f969f97d 100644 --- a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj +++ b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj @@ -14,9 +14,6 @@ - - - diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index 5f359bf99e..43a3fdc940 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -18,7 +18,7 @@ - + all diff --git a/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj b/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj index de870edb61..08500c9389 100644 --- a/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj +++ b/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj @@ -13,10 +13,6 @@ - - - - diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index 08c4af358d..b2378ca09a 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -12,8 +12,6 @@ - - diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj index e98fcd04d1..9ddd95090d 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj @@ -5,8 +5,6 @@ - - From 151fccee97eeabc84039c8c7d7a9224c49883a76 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Tue, 14 Nov 2023 08:12:05 +0100 Subject: [PATCH 26/36] Performance optimisations related to blocks (and multi url picker) (#15184) * Added content types to property index value factory, because the deep cloning is too expensive to execute often * Do not use entityservice in ToEditor, as it goes to the database. We need to use something that is cached. * Small performance optimization for nested content too * A little formatting and an obsoletion message --------- Co-authored-by: kjac --- .../DefaultPropertyIndexValueFactory.cs | 15 +- .../IPropertyIndexValueFactory.cs | 7 +- .../JsonPropertyIndexValueFactoryBase.cs | 47 +++++- .../NoopPropertyIndexValueFactory.cs | 6 + .../Examine/BaseValueSetBuilder.cs | 26 +++- .../Examine/ContentValueSetBuilder.cs | 52 +++++-- .../Examine/MediaValueSetBuilder.cs | 29 +++- .../Examine/MemberValueSetBuilder.cs | 18 ++- .../BlockValuePropertyIndexValueFactory.cs | 5 +- .../MultiUrlPickerValueEditor.cs | 139 +++++++++++------- .../NestedContentPropertyIndexValueFactory.cs | 5 + .../NestedPropertyIndexValueFactoryBase.cs | 27 +++- 12 files changed, 286 insertions(+), 90 deletions(-) diff --git a/src/Umbraco.Core/PropertyEditors/DefaultPropertyIndexValueFactory.cs b/src/Umbraco.Core/PropertyEditors/DefaultPropertyIndexValueFactory.cs index 0193f45778..e0ec2c4ccf 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/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.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/PropertyEditors/BlockValuePropertyIndexValueFactory.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockValuePropertyIndexValueFactory.cs index adace6126e..95185a3f30 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 9a7fa4b3bb..1b76cd89a5 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultiUrlPickerValueEditor.cs @@ -2,19 +2,20 @@ // See LICENSE for more details. using System.Runtime.Serialization; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Newtonsoft.Json; 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.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; -using Umbraco.Extensions; +using Umbraco.Cms.Web.Common.DependencyInjection; namespace Umbraco.Cms.Core.PropertyEditors; @@ -26,11 +27,57 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference NullValueHandling = NullValueHandling.Ignore, }; - private readonly IEntityService _entityService; private readonly ILogger _logger; - private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor; private readonly IPublishedUrlProvider _publishedUrlProvider; + private readonly IContentService _contentService; + private readonly IMediaService _mediaService; + public MultiUrlPickerValueEditor( + ILogger logger, + ILocalizedTextService localizedTextService, + IShortStringHelper shortStringHelper, + DataEditorAttribute attribute, + IPublishedUrlProvider publishedUrlProvider, + IJsonSerializer jsonSerializer, + IIOHelper ioHelper, + IContentService contentService, + IMediaService mediaService) + : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _publishedUrlProvider = publishedUrlProvider; + _contentService = contentService; + _mediaService = mediaService; + } + + [Obsolete("Use non-obsolete constructor. Scheduled for removal in Umbraco 14.")] + public MultiUrlPickerValueEditor( + IEntityService entityService, + IPublishedSnapshotAccessor publishedSnapshotAccessor, + ILogger logger, + ILocalizedTextService localizedTextService, + IShortStringHelper shortStringHelper, + DataEditorAttribute attribute, + IPublishedUrlProvider publishedUrlProvider, + IJsonSerializer jsonSerializer, + IIOHelper ioHelper, + IContentService contentService, + IMediaService mediaService) + :this( + logger, + localizedTextService, + shortStringHelper, + attribute, + publishedUrlProvider, + jsonSerializer, + ioHelper, + contentService, + mediaService) + { + + } + + [Obsolete("Use non-obsolete constructor. Scheduled for removal in Umbraco 14.")] public MultiUrlPickerValueEditor( IEntityService entityService, IPublishedSnapshotAccessor publishedSnapshotAccessor, @@ -41,13 +88,18 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference IPublishedUrlProvider publishedUrlProvider, IJsonSerializer jsonSerializer, IIOHelper ioHelper) - : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) + : this( + logger, + localizedTextService, + shortStringHelper, + attribute, + publishedUrlProvider, + jsonSerializer, + ioHelper, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) { - _entityService = entityService ?? throw new ArgumentNullException(nameof(entityService)); - _publishedSnapshotAccessor = publishedSnapshotAccessor ?? - throw new ArgumentNullException(nameof(publishedSnapshotAccessor)); - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _publishedUrlProvider = publishedUrlProvider; + } public IEnumerable GetReferences(object? value) @@ -86,26 +138,6 @@ public class MultiUrlPickerValueEditor : DataValueEditor, IDataValueReference { List? links = JsonConvert.DeserializeObject>(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) { @@ -114,7 +146,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; @@ -122,35 +154,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); /// From d112ba5e4059d8a61ca43d06b3de0af576f49cdc Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Tue, 14 Nov 2023 09:11:58 +0100 Subject: [PATCH 27/36] Bump version --- version.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.json b/version.json index 006a78b8bd..ca5f5d35d2 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "12.3.1", + "version": "12.3.2", "assemblyVersion": { "precision": "build" }, From 2266a98fc62d7ca9769f9e9dfc16ce9ad91ef726 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Tue, 14 Nov 2023 09:14:01 +0100 Subject: [PATCH 28/36] https://github.com/umbraco/Umbraco-CMS/issues/15195 Fixed issue with media not cached correct (#15196) --- .../Persistence/NuCacheContentRepository.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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"); From 2b7785fcd3bd319afefc0b0b61fae810c64591e8 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Tue, 14 Nov 2023 09:14:01 +0100 Subject: [PATCH 29/36] https://github.com/umbraco/Umbraco-CMS/issues/15195 Fixed issue with media not cached correct (#15196) --- .../Persistence/NuCacheContentRepository.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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"); From 162a6a0085558b3b988921f730012490abce286a Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Tue, 14 Nov 2023 09:39:39 +0100 Subject: [PATCH 30/36] Post merge --- global.json | 4 ++-- .../PropertyEditors/RichTextPropertyIndexValueFactory.cs | 4 ++++ src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj | 1 - .../Umbraco.Core/Services/WebhookRequestServiceTests.cs | 6 +++--- .../Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj | 1 - 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/global.json b/global.json index bf78b4b0a8..17a23f4270 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "7.0.400", + "version": "8.0.100", "rollForward": "latestFeature", - "allowPrerelease": false + "allowPrerelease": true } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs index be49e280cb..a16bdd9fec 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs @@ -57,6 +57,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/Umbraco.Infrastructure.csproj b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj index e20071b19e..9b75b889be 100644 --- a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj +++ b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj @@ -29,7 +29,6 @@ - diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookRequestServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookRequestServiceTests.cs index 3c41aa5ab2..a4e2ded5df 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookRequestServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/WebhookRequestServiceTests.cs @@ -19,7 +19,7 @@ public class WebhookRequestServiceTests : UmbracoIntegrationTest 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.Key, Constants.WebhookEvents.Aliases.ContentPublish, null); + 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); @@ -38,7 +38,7 @@ public class WebhookRequestServiceTests : UmbracoIntegrationTest { 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.Key, Constants.WebhookEvents.Aliases.ContentPublish, null); + 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(); @@ -54,7 +54,7 @@ public class WebhookRequestServiceTests : UmbracoIntegrationTest 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.Key, Constants.WebhookEvents.Aliases.ContentPublish, null); + 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); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj index 392144f4a6..fa49f32f16 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj @@ -6,7 +6,6 @@ - From 737104624e234d2fd273f9d5d9cee762622cecfd Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Tue, 14 Nov 2023 09:56:34 +0100 Subject: [PATCH 31/36] Fixed reference --- src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj b/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj index c0286e713c..26e1aff7e7 100644 --- a/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj +++ b/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj @@ -12,7 +12,6 @@ - From 97aa623e8798e557173c462b68a501aaad734310 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Tue, 14 Nov 2023 10:51:06 +0100 Subject: [PATCH 32/36] Fixed smalled things due to nullable refernece types --- src/Umbraco.Web.Common/Profiler/WebProfiler.cs | 4 ++-- src/Umbraco.Web.Common/Profiler/WebProfilerHtml.cs | 2 +- tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Umbraco.Web.Common/Profiler/WebProfiler.cs b/src/Umbraco.Web.Common/Profiler/WebProfiler.cs index 9608bad715..8767b450fe 100644 --- a/src/Umbraco.Web.Common/Profiler/WebProfiler.cs +++ b/src/Umbraco.Web.Common/Profiler/WebProfiler.cs @@ -29,7 +29,7 @@ public class WebProfiler : IProfiler public void Start() { MiniProfiler.StartNew(); - MiniProfilerContext.Value = MiniProfiler.Current; + MiniProfilerContext.Value = MiniProfiler.Current!; } public void Stop(bool discardResults = false) => MiniProfilerContext.Value?.Stop(discardResults); @@ -84,7 +84,7 @@ public class WebProfiler : IProfiler if (cookieValue is not null) { - AddSubProfiler(MiniProfiler.FromJson(cookieValue)); + AddSubProfiler(MiniProfiler.FromJson(cookieValue)!); } // If it is a redirect to a relative path (local redirect) diff --git a/src/Umbraco.Web.Common/Profiler/WebProfilerHtml.cs b/src/Umbraco.Web.Common/Profiler/WebProfilerHtml.cs index 733715cf53..31319d8b01 100644 --- a/src/Umbraco.Web.Common/Profiler/WebProfilerHtml.cs +++ b/src/Umbraco.Web.Common/Profiler/WebProfilerHtml.cs @@ -34,7 +34,7 @@ public class WebProfilerHtml : IProfilerHtml var result = StackExchange.Profiling.Internal.Render.Includes( profiler, - context is not null ? context.Request.PathBase + path : null, + context is not null ? context.Request.PathBase + path : string.Empty, true, new List { profiler.Id }, RenderPosition.Right, diff --git a/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj b/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj index 067d2c4058..9b538de97e 100644 --- a/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj +++ b/tests/Umbraco.Tests.Common/Umbraco.Tests.Common.csproj @@ -9,7 +9,6 @@ - From 4d98937af9f7f4c4bb42aa382bd8da63f166f348 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Tue, 14 Nov 2023 11:16:12 +0100 Subject: [PATCH 33/36] Delivery API nested property expansion and output limiting (#15124) * V2 output expansion + field limiting incl. deprecation of V1 APIs * Performance optimizations for Content and Block based property editors * A little formatting * Support API versioning in Delivery API endpoint matcher policy * Add V2 "expand" and "fields" to Swagger docs * Renamed route for "multiple items by ID" * Review changes * Update src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdMediaApiController.cs Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> * Update src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> * Update src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> * Revert "Performance optimizations for Content and Block based property editors" This reverts commit 0d5a57956b36e94ce951f1dad7a7f3f43eb1f60b. * Introduce explicit API cache levels for property expansion * Friendly handling of bad expand/fields parameters --------- Co-authored-by: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> --- .../Content/ByIdContentApiController.cs | 24 +- .../Content/ByIdsContentApiController.cs | 23 +- .../Content/ByRouteContentApiController.cs | 18 +- .../Content/QueryContentApiController.cs | 22 +- .../Media/ByIdMediaApiController.cs | 16 +- .../Media/ByIdsMediaApiController.cs | 17 +- .../Media/ByPathMediaApiController.cs | 16 +- .../Media/QueryMediaApiController.cs | 21 +- .../UmbracoBuilderExtensions.cs | 19 +- .../SwaggerContentDocumentationFilter.cs | 4 +- .../Filters/SwaggerDocumentationFilterBase.cs | 105 +++- .../SwaggerMediaDocumentationFilter.cs | 4 +- ...RequestContextOutputExpansionStrategyV2.cs | 185 +++++++ .../DeliveryApiItemsEndpointsMatcherPolicy.cs | 28 +- .../Umbraco.Cms.Api.Delivery.csproj | 3 + .../Extensions/StringExtensions.cs | 7 +- .../IPublishedPropertyType.cs | 6 + .../PublishedContent/PublishedPropertyType.cs | 27 +- .../IDeliveryApiPropertyValueConverter.cs | 9 + .../ContentPickerValueConverter.cs | 2 + .../MediaPickerValueConverter.cs | 2 + .../MultiNodeTreePickerValueConverter.cs | 2 + .../PublishedElementPropertyBase.cs | 13 +- .../BlockGridPropertyValueConverter.cs | 7 + .../BlockListPropertyValueConverter.cs | 3 + .../MediaPickerWithCropsValueConverter.cs | 2 + .../RteMacroRenderingValueConverter.cs | 2 + .../Property.cs | 2 +- .../Umbraco.Core/DeliveryApi/CacheTests.cs | 1 + .../Umbraco.Core/DeliveryApi/CacheTests.cs | 1 + .../DeliveryApi/DeliveryApiTests.cs | 1 + .../OutputExpansionStrategyTestBase.cs | 456 ++++++++++++++++++ .../OutputExpansionStrategyTests.cs | 444 +---------------- .../OutputExpansionStrategyV2Tests.cs | 355 ++++++++++++++ 34 files changed, 1366 insertions(+), 481 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategyV2.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTestBase.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyV2Tests.cs diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdContentApiController.cs index 877e662da7..dab8ccd6d2 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdContentApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdContentApiController.cs @@ -11,6 +11,7 @@ using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Api.Delivery.Controllers.Content; [ApiVersion("1.0")] +[ApiVersion("2.0")] public class ByIdContentApiController : ContentApiItemControllerBase { private readonly IRequestMemberAccessService _requestMemberAccessService; @@ -48,18 +49,31 @@ public class ByIdContentApiController : ContentApiItemControllerBase : base(apiPublishedContentCache, apiContentResponseBuilder) => _requestMemberAccessService = requestMemberAccessService; - /// - /// Gets a content item by id. - /// - /// The unique identifier of the content item. - /// The content item or not found result. [HttpGet("item/{id:guid}")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(IApiContentResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Please use version 2 of this API. Will be removed in V15.")] public async Task ById(Guid id) + => await HandleRequest(id); + + /// + /// Gets a content item by id. + /// + /// The unique identifier of the content item. + /// The content item or not found result. + [HttpGet("item/{id:guid}")] + [MapToApiVersion("2.0")] + [ProducesResponseType(typeof(IApiContentResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task ByIdV20(Guid id) + => await HandleRequest(id); + + private async Task HandleRequest(Guid id) { IPublishedContent? contentItem = ApiPublishedContentCache.GetById(id); diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdsContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdsContentApiController.cs index df7e3b26a4..a58b88a532 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdsContentApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByIdsContentApiController.cs @@ -12,6 +12,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Api.Delivery.Controllers.Content; [ApiVersion("1.0")] +[ApiVersion("2.0")] public class ByIdsContentApiController : ContentApiItemControllerBase { private readonly IRequestMemberAccessService _requestMemberAccessService; @@ -49,17 +50,29 @@ public class ByIdsContentApiController : ContentApiItemControllerBase : base(apiPublishedContentCache, apiContentResponseBuilder) => _requestMemberAccessService = requestMemberAccessService; - /// - /// Gets content items by ids. - /// - /// The unique identifiers of the content items to retrieve. - /// The content items. [HttpGet("item")] [MapToApiVersion("1.0")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] + [Obsolete("Please use version 2 of this API. Will be removed in V15.")] public async Task Item([FromQuery(Name = "id")] HashSet ids) + => await HandleRequest(ids); + + /// + /// Gets content items by ids. + /// + /// The unique identifiers of the content items to retrieve. + /// The content items. + [HttpGet("items")] + [MapToApiVersion("2.0")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + public async Task ItemsV20([FromQuery(Name = "id")] HashSet ids) + => await HandleRequest(ids); + + private async Task HandleRequest(HashSet ids) { IPublishedContent[] contentItems = ApiPublishedContentCache.GetByIds(ids).ToArray(); diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByRouteContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByRouteContentApiController.cs index 4806db45ff..b88147999a 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByRouteContentApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ByRouteContentApiController.cs @@ -13,6 +13,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Api.Delivery.Controllers.Content; [ApiVersion("1.0")] +[ApiVersion("2.0")] public class ByRouteContentApiController : ContentApiItemControllerBase { private readonly IRequestRoutingService _requestRoutingService; @@ -73,6 +74,16 @@ public class ByRouteContentApiController : ContentApiItemControllerBase _requestMemberAccessService = requestMemberAccessService; } + [HttpGet("item/{*path}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IApiContentResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Please use version 2 of this API. Will be removed in V15.")] + public async Task ByRoute(string path = "") + => await HandleRequest(path); + /// /// Gets a content item by route. /// @@ -83,12 +94,15 @@ public class ByRouteContentApiController : ContentApiItemControllerBase /// /// The content item or not found result. [HttpGet("item/{*path}")] - [MapToApiVersion("1.0")] + [MapToApiVersion("2.0")] [ProducesResponseType(typeof(IApiContentResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task ByRoute(string path = "") + public async Task ByRouteV20(string path = "") + => await HandleRequest(path); + + private async Task HandleRequest(string path) { path = DecodePath(path); diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/QueryContentApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/QueryContentApiController.cs index 2f4e8af9c8..d726027021 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/QueryContentApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/QueryContentApiController.cs @@ -15,6 +15,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Api.Delivery.Controllers.Content; [ApiVersion("1.0")] +[ApiVersion("2.0")] public class QueryContentApiController : ContentApiControllerBase { private readonly IRequestMemberAccessService _requestMemberAccessService; @@ -45,6 +46,20 @@ public class QueryContentApiController : ContentApiControllerBase _requestMemberAccessService = requestMemberAccessService; } + [HttpGet] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Please use version 2 of this API. Will be removed in V15.")] + public async Task Query( + string? fetch, + [FromQuery] string[] filter, + [FromQuery] string[] sort, + int skip = 0, + int take = 10) + => await HandleRequest(fetch, filter, sort, skip, take); + /// /// Gets a paginated list of content item(s) from query. /// @@ -55,16 +70,19 @@ public class QueryContentApiController : ContentApiControllerBase /// The amount of items to take. /// The paged result of the content item(s). [HttpGet] - [MapToApiVersion("1.0")] + [MapToApiVersion("2.0")] [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task Query( + public async Task QueryV20( string? fetch, [FromQuery] string[] filter, [FromQuery] string[] sort, int skip = 0, int take = 10) + => await HandleRequest(fetch, filter, sort, skip, take); + + private async Task HandleRequest(string? fetch, string[] filter, string[] sort, int skip, int take) { ProtectedAccess protectedAccess = await _requestMemberAccessService.MemberAccessAsync(); Attempt, ApiContentQueryOperationStatus> queryAttempt = _apiContentQueryService.ExecuteQuery(fetch, filter, sort, protectedAccess, skip, take); diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdMediaApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdMediaApiController.cs index b0242bea5d..76ce80898f 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdMediaApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdMediaApiController.cs @@ -9,6 +9,7 @@ using Umbraco.Cms.Infrastructure.DeliveryApi; namespace Umbraco.Cms.Api.Delivery.Controllers.Media; [ApiVersion("1.0")] +[ApiVersion("2.0")] public class ByIdMediaApiController : MediaApiControllerBase { public ByIdMediaApiController(IPublishedSnapshotAccessor publishedSnapshotAccessor, IApiMediaWithCropsResponseBuilder apiMediaWithCropsResponseBuilder) @@ -16,16 +17,27 @@ public class ByIdMediaApiController : MediaApiControllerBase { } + [HttpGet("item/{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IApiMediaWithCropsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Please use version 2 of this API. Will be removed in V15.")] + public async Task ById(Guid id) + => await HandleRequest(id); + /// /// Gets a media item by id. /// /// The unique identifier of the media item. /// The media item or not found result. [HttpGet("item/{id:guid}")] - [MapToApiVersion("1.0")] + [MapToApiVersion("2.0")] [ProducesResponseType(typeof(IApiMediaWithCropsResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task ById(Guid id) + public async Task ByIdV20(Guid id) + => await HandleRequest(id); + + private async Task HandleRequest(Guid id) { IPublishedContent? media = PublishedMediaCache.GetById(id); diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdsMediaApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdsMediaApiController.cs index a9421eaa0c..8a3f4c7ceb 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdsMediaApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByIdsMediaApiController.cs @@ -10,6 +10,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Api.Delivery.Controllers.Media; [ApiVersion("1.0")] +[ApiVersion("2.0")] public class ByIdsMediaApiController : MediaApiControllerBase { public ByIdsMediaApiController(IPublishedSnapshotAccessor publishedSnapshotAccessor, IApiMediaWithCropsResponseBuilder apiMediaWithCropsResponseBuilder) @@ -17,15 +18,25 @@ public class ByIdsMediaApiController : MediaApiControllerBase { } + [HttpGet("item")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] + [Obsolete("Please use version 2 of this API. Will be removed in V15.")] + public async Task Item([FromQuery(Name = "id")] HashSet ids) + => await HandleRequest(ids); + /// /// Gets media items by ids. /// /// The unique identifiers of the media items to retrieve. /// The media items. - [HttpGet("item")] - [MapToApiVersion("1.0")] + [HttpGet("items")] + [MapToApiVersion("2.0")] [ProducesResponseType(typeof(IEnumerable), StatusCodes.Status200OK)] - public async Task Item([FromQuery(Name = "id")] HashSet ids) + public async Task ItemsV20([FromQuery(Name = "id")] HashSet ids) + => await HandleRequest(ids); + + private async Task HandleRequest(HashSet ids) { IPublishedContent[] mediaItems = ids .Select(PublishedMediaCache.GetById) diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByPathMediaApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByPathMediaApiController.cs index 1d725ac5ab..ed5dc90187 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByPathMediaApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Media/ByPathMediaApiController.cs @@ -10,6 +10,7 @@ using Umbraco.Cms.Infrastructure.DeliveryApi; namespace Umbraco.Cms.Api.Delivery.Controllers.Media; [ApiVersion("1.0")] +[ApiVersion("2.0")] public class ByPathMediaApiController : MediaApiControllerBase { private readonly IApiMediaQueryService _apiMediaQueryService; @@ -21,16 +22,27 @@ public class ByPathMediaApiController : MediaApiControllerBase : base(publishedSnapshotAccessor, apiMediaWithCropsResponseBuilder) => _apiMediaQueryService = apiMediaQueryService; + [HttpGet("item/{*path}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(IApiMediaWithCropsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Obsolete("Please use version 2 of this API. Will be removed in V15.")] + public async Task ByPath(string path) + => await HandleRequest(path); + /// /// Gets a media item by its path. /// /// The path of the media item. /// The media item or not found result. [HttpGet("item/{*path}")] - [MapToApiVersion("1.0")] + [MapToApiVersion("2.0")] [ProducesResponseType(typeof(IApiMediaWithCropsResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public async Task ByPath(string path) + public async Task ByPathV20(string path) + => await HandleRequest(path); + + private async Task HandleRequest(string path) { path = DecodePath(path); diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Media/QueryMediaApiController.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Media/QueryMediaApiController.cs index 5d962ea4bf..de872e5d86 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Media/QueryMediaApiController.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Media/QueryMediaApiController.cs @@ -15,6 +15,7 @@ using Umbraco.Extensions; namespace Umbraco.Cms.Api.Delivery.Controllers.Media; [ApiVersion("1.0")] +[ApiVersion("2.0")] public class QueryMediaApiController : MediaApiControllerBase { private readonly IApiMediaQueryService _apiMediaQueryService; @@ -26,6 +27,19 @@ public class QueryMediaApiController : MediaApiControllerBase : base(publishedSnapshotAccessor, apiMediaWithCropsResponseBuilder) => _apiMediaQueryService = apiMediaQueryService; + [HttpGet] + [MapToApiVersion("1.0")] + [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [Obsolete("Please use version 2 of this API. Will be removed in V15.")] + public async Task Query( + string? fetch, + [FromQuery] string[] filter, + [FromQuery] string[] sort, + int skip = 0, + int take = 10) + => await HandleRequest(fetch, filter, sort, skip, take); + /// /// Gets a paginated list of media item(s) from query. /// @@ -36,15 +50,18 @@ public class QueryMediaApiController : MediaApiControllerBase /// The amount of items to take. /// The paged result of the media item(s). [HttpGet] - [MapToApiVersion("1.0")] + [MapToApiVersion("2.0")] [ProducesResponseType(typeof(PagedViewModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] - public async Task Query( + public async Task QueryV20( string? fetch, [FromQuery] string[] filter, [FromQuery] string[] sort, int skip = 0, int take = 10) + => await HandleRequest(fetch, filter, sort, skip, take); + + private async Task HandleRequest(string? fetch, string[] filter, string[] sort, int skip, int take) { Attempt, ApiMediaQueryOperationStatus> queryAttempt = _apiMediaQueryService.ExecuteQuery(fetch, filter, sort, skip, take); diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index 86c8583708..f17fc14773 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -1,5 +1,7 @@ using System.Text.Json; using System.Text.Json.Serialization; +using Asp.Versioning; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Common.DependencyInjection; @@ -24,7 +26,22 @@ public static class UmbracoBuilderExtensions public static IUmbracoBuilder AddDeliveryApi(this IUmbracoBuilder builder) { builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(provider => + { + HttpContext? httpContext = provider.GetRequiredService().HttpContext; + ApiVersion? apiVersion = httpContext?.GetRequestedApiVersion(); + if (apiVersion is null) + { + return provider.GetRequiredService(); + } + + // V1 of the Delivery API uses a different expansion strategy than V2+ + return apiVersion.MajorVersion == 1 + ? provider.GetRequiredService() + : provider.GetRequiredService(); + }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs index 7c42a3d0aa..9d938cef41 100644 --- a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs @@ -15,7 +15,9 @@ internal sealed class SwaggerContentDocumentationFilter : SwaggerDocumentationFi { operation.Parameters ??= new List(); - AddExpand(operation); + AddExpand(operation, context); + + AddFields(operation, context); operation.Parameters.Add(new OpenApiParameter { diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs index 36721cc0f2..52acddaca9 100644 --- a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerDocumentationFilterBase.cs @@ -37,8 +37,52 @@ internal abstract class SwaggerDocumentationFilterBase parameter.Examples = examples; } - protected void AddExpand(OpenApiOperation operation) => + protected void AddExpand(OpenApiOperation operation, OperationFilterContext context) + { + if (IsApiV1(context)) + { + AddExpandV1(operation); + } + else + { + AddExpand(operation); + } + } + + protected void AddFields(OpenApiOperation operation, OperationFilterContext context) + { + if (IsApiV1(context)) + { + // "fields" is not a thing in Delivery API V1 + return; + } + + AddFields(operation); + } + + protected void AddApiKey(OpenApiOperation operation) => operation.Parameters.Add(new OpenApiParameter + { + Name = "Api-Key", + In = ParameterLocation.Header, + Required = false, + Description = "API key specified through configuration to authorize access to the API.", + Schema = new OpenApiSchema { Type = "string" } + }); + + protected string PaginationDescription(bool skip, string itemType) + => $"Specifies the number of found {itemType} items to {(skip ? "skip" : "take")}. Use this to control pagination of the response."; + + private string QueryParameterDescription(string description) + => $"{description}. Refer to [the documentation]({DocumentationLink}#query-parameters) for more details on this."; + + // FIXME: remove this when Delivery API V1 has been removed (expectedly in V15) + private static bool IsApiV1(OperationFilterContext context) + => context.ApiDescription.RelativePath?.Contains("api/v1") is true; + + // FIXME: remove this when Delivery API V1 has been removed (expectedly in V15) + private void AddExpandV1(OpenApiOperation operation) + => operation.Parameters.Add(new OpenApiParameter { Name = "expand", In = ParameterLocation.Query, @@ -60,19 +104,56 @@ internal abstract class SwaggerDocumentationFilterBase } }); - protected void AddApiKey(OpenApiOperation operation) => - operation.Parameters.Add(new OpenApiParameter + private void AddExpand(OpenApiOperation operation) + => operation.Parameters.Add(new OpenApiParameter { - Name = "Api-Key", - In = ParameterLocation.Header, + Name = "expand", + In = ParameterLocation.Query, Required = false, - Description = "API key specified through configuration to authorize access to the API.", - Schema = new OpenApiSchema { Type = "string" } + Description = QueryParameterDescription("Defines the properties that should be expanded in the response"), + Schema = new OpenApiSchema { Type = "string" }, + Examples = new Dictionary + { + { "Expand none", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { "Expand all properties", new OpenApiExample { Value = new OpenApiString("properties[$all]") } }, + { + "Expand specific property", + new OpenApiExample { Value = new OpenApiString("properties[alias1]") } + }, + { + "Expand specific properties", + new OpenApiExample { Value = new OpenApiString("properties[alias1,alias2]") } + }, + { + "Expand nested properties", + new OpenApiExample { Value = new OpenApiString("properties[alias1[properties[nestedAlias1,nestedAlias2]]]") } + } + } }); - protected string PaginationDescription(bool skip, string itemType) - => $"Specifies the number of found {itemType} items to {(skip ? "skip" : "take")}. Use this to control pagination of the response."; - - private string QueryParameterDescription(string description) - => $"{description}. Refer to [the documentation]({DocumentationLink}#query-parameters) for more details on this."; + private void AddFields(OpenApiOperation operation) + => operation.Parameters.Add(new OpenApiParameter + { + Name = "fields", + In = ParameterLocation.Query, + Required = false, + Description = QueryParameterDescription("Explicitly defines which properties should be included in the response (by default all properties are included)"), + Schema = new OpenApiSchema { Type = "string" }, + Examples = new Dictionary + { + { "Include all properties", new OpenApiExample { Value = new OpenApiString("properties[$all]") } }, + { + "Include only specific property", + new OpenApiExample { Value = new OpenApiString("properties[alias1]") } + }, + { + "Include only specific properties", + new OpenApiExample { Value = new OpenApiString("properties[alias1,alias2]") } + }, + { + "Include only specific nested properties", + new OpenApiExample { Value = new OpenApiString("properties[alias1[properties[nestedAlias1,nestedAlias2]]]") } + } + } + }); } diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerMediaDocumentationFilter.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerMediaDocumentationFilter.cs index 8529178888..85ba66e648 100644 --- a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerMediaDocumentationFilter.cs +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerMediaDocumentationFilter.cs @@ -15,7 +15,9 @@ internal sealed class SwaggerMediaDocumentationFilter : SwaggerDocumentationFilt { operation.Parameters ??= new List(); - AddExpand(operation); + AddExpand(operation, context); + + AddFields(operation, context); AddApiKey(operation); } diff --git a/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategyV2.cs b/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategyV2.cs new file mode 100644 index 0000000000..6cc2100e93 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Rendering/RequestContextOutputExpansionStrategyV2.cs @@ -0,0 +1,185 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Delivery.Rendering; + +internal sealed class RequestContextOutputExpansionStrategyV2 : IOutputExpansionStrategy +{ + private const string All = "$all"; + private const string None = ""; + private const string ExpandParameterName = "expand"; + private const string FieldsParameterName = "fields"; + + private readonly IApiPropertyRenderer _propertyRenderer; + private readonly ILogger _logger; + + private readonly Stack _expandProperties; + private readonly Stack _includeProperties; + + public RequestContextOutputExpansionStrategyV2( + IHttpContextAccessor httpContextAccessor, + IApiPropertyRenderer propertyRenderer, + ILogger logger) + { + _propertyRenderer = propertyRenderer; + _logger = logger; + _expandProperties = new Stack(); + _includeProperties = new Stack(); + + InitializeExpandAndInclude(httpContextAccessor); + } + + public IDictionary MapContentProperties(IPublishedContent content) + => content.ItemType == PublishedItemType.Content + ? MapProperties(content.Properties) + : throw new ArgumentException($"Invalid item type. This method can only be used with item type {nameof(PublishedItemType.Content)}, got: {content.ItemType}"); + + public IDictionary MapMediaProperties(IPublishedContent media, bool skipUmbracoProperties = true) + { + if (media.ItemType != PublishedItemType.Media) + { + throw new ArgumentException($"Invalid item type. This method can only be used with item type {PublishedItemType.Media}, got: {media.ItemType}"); + } + + IPublishedProperty[] properties = media + .Properties + .Where(p => skipUmbracoProperties is false || p.Alias.StartsWith("umbraco") is false) + .ToArray(); + + return properties.Any() + ? MapProperties(properties) + : new Dictionary(); + } + + public IDictionary MapElementProperties(IPublishedElement element) + => MapProperties(element.Properties, true); + + private void InitializeExpandAndInclude(IHttpContextAccessor httpContextAccessor) + { + string? QueryValue(string key) => httpContextAccessor.HttpContext?.Request.Query[key]; + + var toExpand = QueryValue(ExpandParameterName) ?? None; + var toInclude = QueryValue(FieldsParameterName) ?? All; + + try + { + _expandProperties.Push(Node.Parse(toExpand)); + } + catch (ArgumentException ex) + { + _logger.LogError(ex, $"Could not parse the '{ExpandParameterName}' parameter. See exception for details."); + throw new ArgumentException($"Could not parse the '{ExpandParameterName}' parameter: {ex.Message}"); + } + + try + { + _includeProperties.Push(Node.Parse(toInclude)); + } + catch (ArgumentException ex) + { + _logger.LogError(ex, $"Could not parse the '{FieldsParameterName}' parameter. See exception for details."); + throw new ArgumentException($"Could not parse the '{FieldsParameterName}' parameter: {ex.Message}"); + } + } + + private IDictionary MapProperties(IEnumerable properties, bool forceExpandProperties = false) + { + Node? currentExpandProperties = _expandProperties.Peek(); + if (_expandProperties.Count > 1 && currentExpandProperties is null && forceExpandProperties is false) + { + return new Dictionary(); + } + + Node? currentIncludeProperties = _includeProperties.Peek(); + var result = new Dictionary(); + foreach (IPublishedProperty property in properties) + { + Node? nextIncludeProperties = GetNextProperties(currentIncludeProperties, property.Alias); + if (currentIncludeProperties is not null && currentIncludeProperties.Items.Any() && nextIncludeProperties is null) + { + continue; + } + + Node? nextExpandProperties = GetNextProperties(currentExpandProperties, property.Alias); + + _includeProperties.Push(nextIncludeProperties); + _expandProperties.Push(nextExpandProperties); + + result[property.Alias] = GetPropertyValue(property); + + _expandProperties.Pop(); + _includeProperties.Pop(); + } + + return result; + } + + private Node? GetNextProperties(Node? currentProperties, string propertyAlias) + => currentProperties?.Items.FirstOrDefault(i => i.Key == All) + ?? currentProperties?.Items.FirstOrDefault(i => i.Key == "properties")?.Items.FirstOrDefault(i => i.Key == All || i.Key == propertyAlias); + + private object? GetPropertyValue(IPublishedProperty property) + => _propertyRenderer.GetPropertyValue(property, _expandProperties.Peek() is not null); + + private class Node + { + public string Key { get; private set; } = string.Empty; + + public List Items { get; } = new(); + + public static Node Parse(string value) + { + // verify that there are as many start brackets as there are end brackets + if (value.CountOccurrences("[") != value.CountOccurrences("]")) + { + throw new ArgumentException("Value did not contain an equal number of start and end brackets"); + } + + // verify that the value does not start with a start bracket + if (value.StartsWith("[")) + { + throw new ArgumentException("Value cannot start with a bracket"); + } + + // verify that there are no empty brackets + if (value.Contains("[]")) + { + throw new ArgumentException("Value cannot contain empty brackets"); + } + + var stack = new Stack(); + var root = new Node { Key = "root" }; + stack.Push(root); + + var currentNode = new Node(); + root.Items.Add(currentNode); + + foreach (char c in value) + { + switch (c) + { + case '[': // Start a new node, child of the current node + stack.Push(currentNode); + currentNode = new Node(); + stack.Peek().Items.Add(currentNode); + break; + case ',': // Start a new node, but at the same level of the current node + currentNode = new Node(); + stack.Peek().Items.Add(currentNode); + break; + case ']': // Back to parent of the current node + currentNode = stack.Pop(); + break; + default: // Add char to current node key + currentNode.Key += c; + break; + } + } + + return root; + } + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Routing/DeliveryApiItemsEndpointsMatcherPolicy.cs b/src/Umbraco.Cms.Api.Delivery/Routing/DeliveryApiItemsEndpointsMatcherPolicy.cs index 52b38414f8..186cd4f555 100644 --- a/src/Umbraco.Cms.Api.Delivery/Routing/DeliveryApiItemsEndpointsMatcherPolicy.cs +++ b/src/Umbraco.Cms.Api.Delivery/Routing/DeliveryApiItemsEndpointsMatcherPolicy.cs @@ -1,3 +1,4 @@ +using Asp.Versioning; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Controllers; using Microsoft.AspNetCore.Routing; @@ -29,9 +30,25 @@ internal sealed class DeliveryApiItemsEndpointsMatcherPolicy : MatcherPolicy, IE public Task ApplyAsync(HttpContext httpContext, CandidateSet candidates) { var hasIdQueryParameter = httpContext.Request.Query.ContainsKey("id"); + ApiVersion? requestedApiVersion = httpContext.GetRequestedApiVersion(); for (var i = 0; i < candidates.Count; i++) { - ControllerActionDescriptor? controllerActionDescriptor = candidates[i].Endpoint?.Metadata.GetMetadata(); + CandidateState candidate = candidates[i]; + Endpoint? endpoint = candidate.Endpoint; + + // NOTE: nullability for the CandidateState.Endpoint property is not correct - it *can* be null + if (endpoint is null) + { + continue; + } + + if (EndpointSupportsApiVersion(endpoint, requestedApiVersion) is false) + { + candidates.SetValidity(i, false); + continue; + } + + ControllerActionDescriptor? controllerActionDescriptor = endpoint.Metadata.GetMetadata(); if (IsByIdsController(controllerActionDescriptor)) { candidates.SetValidity(i, hasIdQueryParameter); @@ -45,6 +62,15 @@ internal sealed class DeliveryApiItemsEndpointsMatcherPolicy : MatcherPolicy, IE return Task.CompletedTask; } + private static bool EndpointSupportsApiVersion(Endpoint endpoint, ApiVersion? requestedApiVersion) + { + ApiVersion[]? supportedApiVersions = endpoint.Metadata.GetMetadata()?.Versions.ToArray(); + + // if the endpoint is versioned, the requested API version must be among the API versions supported by the endpoint. + // if the endpoint is NOT versioned, it cannot be used with a requested API version + return supportedApiVersions?.Contains(requestedApiVersion) ?? requestedApiVersion is null; + } + private static bool IsByIdsController(ControllerActionDescriptor? controllerActionDescriptor) => IsControllerType(controllerActionDescriptor) || IsControllerType(controllerActionDescriptor); diff --git a/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj b/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj index 0306dab5af..8948a49a1a 100644 --- a/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj +++ b/src/Umbraco.Cms.Api.Delivery/Umbraco.Cms.Api.Delivery.csproj @@ -13,5 +13,8 @@ <_Parameter1>Umbraco.Tests.UnitTests + + <_Parameter1>DynamicProxyGenAssembly2 + diff --git a/src/Umbraco.Core/Extensions/StringExtensions.cs b/src/Umbraco.Core/Extensions/StringExtensions.cs index e7849eef12..732e25ecc2 100644 --- a/src/Umbraco.Core/Extensions/StringExtensions.cs +++ b/src/Umbraco.Core/Extensions/StringExtensions.cs @@ -419,7 +419,7 @@ public static class StringExtensions /// returns . /// public static bool IsNullOrWhiteSpace([NotNullWhen(false)] this string? value) => string.IsNullOrWhiteSpace(value); - + [return: NotNullIfNotNull("defaultValue")] public static string? IfNullOrWhiteSpace(this string? str, string? defaultValue) => str.IsNullOrWhiteSpace() ? defaultValue : str; @@ -1557,4 +1557,9 @@ public static class StringExtensions yield return sb.ToString(); } + + // having benchmarked various solutions (incl. for/foreach, split and LINQ based ones), + // this is by far the fastest way to find string needles in a string haystack + public static int CountOccurrences(this string haystack, string needle) + => haystack.Length - haystack.Replace(needle, string.Empty).Length; } diff --git a/src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs b/src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs index 45d36abb6a..4cf5bdd6af 100644 --- a/src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs +++ b/src/Umbraco.Core/Models/PublishedContent/IPublishedPropertyType.cs @@ -56,6 +56,12 @@ public interface IPublishedPropertyType /// PropertyCacheLevel DeliveryApiCacheLevel { get; } + /// + /// Gets the property cache level for Delivery API representation when expanding the property. + /// + /// Defaults to the value of . + PropertyCacheLevel DeliveryApiCacheLevelForExpansion => DeliveryApiCacheLevel; + /// /// Gets the property model CLR type. /// diff --git a/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs b/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs index 848e961d0b..52e3371767 100644 --- a/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs +++ b/src/Umbraco.Core/Models/PublishedContent/PublishedPropertyType.cs @@ -21,6 +21,7 @@ namespace Umbraco.Cms.Core.Models.PublishedContent private IPropertyValueConverter? _converter; private PropertyCacheLevel _cacheLevel; private PropertyCacheLevel _deliveryApiCacheLevel; + private PropertyCacheLevel _deliveryApiCacheLevelForExpansion; private Type? _modelClrType; private Type? _clrType; @@ -192,9 +193,15 @@ namespace Umbraco.Cms.Core.Models.PublishedContent } _cacheLevel = _converter?.GetPropertyCacheLevel(this) ?? PropertyCacheLevel.Snapshot; - _deliveryApiCacheLevel = _converter is IDeliveryApiPropertyValueConverter deliveryApiPropertyValueConverter - ? deliveryApiPropertyValueConverter.GetDeliveryApiPropertyCacheLevel(this) - : _cacheLevel; + if (_converter is IDeliveryApiPropertyValueConverter deliveryApiPropertyValueConverter) + { + _deliveryApiCacheLevel = deliveryApiPropertyValueConverter.GetDeliveryApiPropertyCacheLevel(this); + _deliveryApiCacheLevelForExpansion = deliveryApiPropertyValueConverter.GetDeliveryApiPropertyCacheLevelForExpansion(this); + } + else + { + _deliveryApiCacheLevel = _deliveryApiCacheLevelForExpansion = _cacheLevel; + } _modelClrType = _converter?.GetPropertyValueType(this) ?? typeof(object); } @@ -244,6 +251,20 @@ namespace Umbraco.Cms.Core.Models.PublishedContent } } + /// + public PropertyCacheLevel DeliveryApiCacheLevelForExpansion + { + get + { + if (!_initialized) + { + Initialize(); + } + + return _deliveryApiCacheLevelForExpansion; + } + } + /// public object? ConvertSourceToInter(IPublishedElement owner, object? source, bool preview) { diff --git a/src/Umbraco.Core/PropertyEditors/DeliveryApi/IDeliveryApiPropertyValueConverter.cs b/src/Umbraco.Core/PropertyEditors/DeliveryApi/IDeliveryApiPropertyValueConverter.cs index 51d9f95873..4d539d95ce 100644 --- a/src/Umbraco.Core/PropertyEditors/DeliveryApi/IDeliveryApiPropertyValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/DeliveryApi/IDeliveryApiPropertyValueConverter.cs @@ -11,6 +11,15 @@ public interface IDeliveryApiPropertyValueConverter : IPropertyValueConverter /// The property cache level. PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType); + /// + /// Gets the property cache level for Delivery API representation when expanding the property. + /// + /// The property type. + /// The property cache level. + /// Defaults to the value of . + PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) + => GetDeliveryApiPropertyCacheLevel(propertyType); + /// /// Gets the type of values returned by the converter for Delivery API representation. /// diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs index 94f2533548..e13f4c3e45 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/ContentPickerValueConverter.cs @@ -99,6 +99,8 @@ public class ContentPickerValueConverter : PropertyValueConverterBase, IDelivery public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType); + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot; + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(IApiContent); public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview, bool expanding) diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/MediaPickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/MediaPickerValueConverter.cs index 468d4cdd0c..9c7c481752 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/MediaPickerValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/MediaPickerValueConverter.cs @@ -124,6 +124,8 @@ public class MediaPickerValueConverter : PropertyValueConverterBase, IDeliveryAp public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Elements; + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot; + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(IEnumerable); public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview, bool expanding) diff --git a/src/Umbraco.Core/PropertyEditors/ValueConverters/MultiNodeTreePickerValueConverter.cs b/src/Umbraco.Core/PropertyEditors/ValueConverters/MultiNodeTreePickerValueConverter.cs index c14a56a0bd..7d512fbb2b 100644 --- a/src/Umbraco.Core/PropertyEditors/ValueConverters/MultiNodeTreePickerValueConverter.cs +++ b/src/Umbraco.Core/PropertyEditors/ValueConverters/MultiNodeTreePickerValueConverter.cs @@ -183,6 +183,8 @@ public class MultiNodeTreePickerValueConverter : PropertyValueConverterBase, IDe public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Elements; + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot; + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => GetEntityType(propertyType) switch { diff --git a/src/Umbraco.Core/PublishedCache/PublishedElementPropertyBase.cs b/src/Umbraco.Core/PublishedCache/PublishedElementPropertyBase.cs index 05348a138c..09f63a8254 100644 --- a/src/Umbraco.Core/PublishedCache/PublishedElementPropertyBase.cs +++ b/src/Umbraco.Core/PublishedCache/PublishedElementPropertyBase.cs @@ -89,6 +89,9 @@ internal class PublishedElementPropertyBase : PublishedPropertyBase private void GetDeliveryApiCacheLevels(out PropertyCacheLevel cacheLevel, out PropertyCacheLevel referenceCacheLevel) => GetCacheLevels(PropertyType.DeliveryApiCacheLevel, out cacheLevel, out referenceCacheLevel); + private void GetDeliveryApiCacheLevelsForExpansion(out PropertyCacheLevel cacheLevel, out PropertyCacheLevel referenceCacheLevel) + => GetCacheLevels(PropertyType.DeliveryApiCacheLevelForExpansion, out cacheLevel, out referenceCacheLevel); + private void GetCacheLevels(PropertyCacheLevel propertyTypeCacheLevel, out PropertyCacheLevel cacheLevel, out PropertyCacheLevel referenceCacheLevel) { // based upon the current reference cache level (ReferenceCacheLevel) and this property @@ -223,7 +226,15 @@ internal class PublishedElementPropertyBase : PublishedPropertyBase public override object? GetDeliveryApiValue(bool expanding, string? culture = null, string? segment = null) { - GetDeliveryApiCacheLevels(out PropertyCacheLevel cacheLevel, out PropertyCacheLevel referenceCacheLevel); + PropertyCacheLevel cacheLevel, referenceCacheLevel; + if (expanding) + { + GetDeliveryApiCacheLevelsForExpansion(out cacheLevel, out referenceCacheLevel); + } + else + { + GetDeliveryApiCacheLevels(out cacheLevel, out referenceCacheLevel); + } lock (_locko) { diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueConverter.cs index 5b877bd9b9..d0e1e2ba19 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockGridPropertyValueConverter.cs @@ -52,6 +52,10 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters public override Type GetPropertyValueType(IPublishedPropertyType propertyType) => typeof(BlockGridModel); + /// + public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) + => PropertyCacheLevel.Element; + /// public override object? ConvertIntermediateToObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview) => ConvertIntermediateToBlockGridModel(propertyType, referenceCacheLevel, inter, preview); @@ -59,6 +63,9 @@ namespace Umbraco.Cms.Core.PropertyEditors.ValueConverters /// public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType); + /// + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot; + /// public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(ApiBlockGridModel); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs index 4c65963093..03445094c1 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/BlockListPropertyValueConverter.cs @@ -111,6 +111,9 @@ public class BlockListPropertyValueConverter : PropertyValueConverterBase, IDeli /// public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => GetPropertyCacheLevel(propertyType); + /// + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot; + /// public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(ApiBlockListModel); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs index 817d48687a..8a22ca92fe 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/MediaPickerWithCropsValueConverter.cs @@ -151,6 +151,8 @@ public class MediaPickerWithCropsValueConverter : PropertyValueConverterBase, ID public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Elements; + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot; + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => typeof(IEnumerable); public object? ConvertIntermediateToDeliveryApiObject(IPublishedElement owner, IPublishedPropertyType propertyType, PropertyCacheLevel referenceCacheLevel, object? inter, bool preview, bool expanding) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs index 649fbf36df..871595b3bc 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ValueConverters/RteMacroRenderingValueConverter.cs @@ -144,6 +144,8 @@ public class RteMacroRenderingValueConverter : SimpleTinyMceValueConverter, IDel public PropertyCacheLevel GetDeliveryApiPropertyCacheLevel(IPublishedPropertyType propertyType) => PropertyCacheLevel.Elements; + public PropertyCacheLevel GetDeliveryApiPropertyCacheLevelForExpansion(IPublishedPropertyType propertyType) => PropertyCacheLevel.Snapshot; + public Type GetDeliveryApiPropertyValueType(IPublishedPropertyType propertyType) => _deliveryApiSettings.RichTextOutputAsJson ? typeof(IRichTextElement) diff --git a/src/Umbraco.PublishedCache.NuCache/Property.cs b/src/Umbraco.PublishedCache.NuCache/Property.cs index 6a9e1a982c..2892a04f90 100644 --- a/src/Umbraco.PublishedCache.NuCache/Property.cs +++ b/src/Umbraco.PublishedCache.NuCache/Property.cs @@ -323,7 +323,7 @@ internal class Property : PublishedPropertyBase object? value; lock (_locko) { - CacheValue cacheValues = GetCacheValues(PropertyType.DeliveryApiCacheLevel).For(culture, segment); + CacheValue cacheValues = GetCacheValues(expanding ? PropertyType.DeliveryApiCacheLevelForExpansion : PropertyType.DeliveryApiCacheLevel).For(culture, segment); // initial reference cache level always is .Content const PropertyCacheLevel initialCacheLevel = PropertyCacheLevel.Element; diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/CacheTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/CacheTests.cs index 750be34ea1..7c13e5780d 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/CacheTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/CacheTests.cs @@ -49,6 +49,7 @@ public class CacheTests var invocationCount = 0; propertyType.SetupGet(p => p.CacheLevel).Returns(cacheLevel); propertyType.SetupGet(p => p.DeliveryApiCacheLevel).Returns(cacheLevel); + propertyType.SetupGet(p => p.DeliveryApiCacheLevelForExpansion).Returns(cacheLevel); propertyType .Setup(p => p.ConvertInterToDeliveryApiObject(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(() => $"Delivery API value: {++invocationCount}"); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/CacheTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/CacheTests.cs index 48007a13bb..194abdc158 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/CacheTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/CacheTests.cs @@ -33,6 +33,7 @@ public class CacheTests : DeliveryApiTests propertyValueConverter.Setup(p => p.IsConverter(It.IsAny())).Returns(true); propertyValueConverter.Setup(p => p.GetPropertyCacheLevel(It.IsAny())).Returns(cacheLevel); propertyValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(cacheLevel); + propertyValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevelForExpansion(It.IsAny())).Returns(cacheLevel); var propertyType = SetupPublishedPropertyType(propertyValueConverter.Object, "something", "Some.Thing"); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs index 886948697a..80733e3cdd 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/DeliveryApiTests.cs @@ -43,6 +43,7 @@ public class DeliveryApiTests deliveryApiPropertyValueConverter.Setup(p => p.IsValue(It.IsAny(), It.IsAny())).Returns(true); deliveryApiPropertyValueConverter.Setup(p => p.GetPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); deliveryApiPropertyValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); + deliveryApiPropertyValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevelForExpansion(It.IsAny())).Returns(PropertyCacheLevel.None); DeliveryApiPropertyType = SetupPublishedPropertyType(deliveryApiPropertyValueConverter.Object, "deliveryApi", "Delivery.Api.Editor"); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTestBase.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTestBase.cs new file mode 100644 index 0000000000..f08ea5545e --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTestBase.cs @@ -0,0 +1,456 @@ +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; +using Umbraco.Cms.Core.PropertyEditors.ValueConverters; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Infrastructure.DeliveryApi; +using Umbraco.Cms.Infrastructure.Serialization; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +/// +/// The tests contained within this class all serve to test property expansion V1 and V2 exactly the same. +/// +public abstract class OutputExpansionStrategyTestBase : PropertyValueConverterTests +{ + private IPublishedContentType _contentType; + private IPublishedContentType _elementType; + private IPublishedContentType _mediaType; + + [SetUp] + public void SetUp() + { + var contentType = new Mock(); + contentType.SetupGet(c => c.Alias).Returns("thePageType"); + contentType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Content); + _contentType = contentType.Object; + var elementType = new Mock(); + elementType.SetupGet(c => c.Alias).Returns("theElementType"); + elementType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Element); + _elementType = elementType.Object; + var mediaType = new Mock(); + mediaType.SetupGet(c => c.Alias).Returns("theMediaType"); + mediaType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Media); + _mediaType = mediaType.Object; + } + + [Test] + public void OutputExpansionStrategy_ExpandsNothingByDefault() + { + var accessor = CreateOutputExpansionStrategyAccessor(false); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + var prop1 = new PublishedElementPropertyBase(DeliveryApiPropertyType, content.Object, false, PropertyCacheLevel.None); + var prop2 = new PublishedElementPropertyBase(DefaultPropertyType, content.Object, false, PropertyCacheLevel.None); + + var contentPickerContent = CreateSimplePickedContent(123, 456); + var contentPickerProperty = CreateContentPickerProperty(content.Object, contentPickerContent.Key, "contentPicker", apiContentBuilder); + + SetupContentMock(content, prop1, prop2, contentPickerProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(3, result.Properties.Count); + Assert.AreEqual("Delivery API value", result.Properties[DeliveryApiPropertyType.Alias]); + Assert.AreEqual("Default value", result.Properties[DefaultPropertyType.Alias]); + var contentPickerOutput = result.Properties["contentPicker"] as ApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(contentPickerContent.Key, contentPickerOutput.Id); + Assert.IsEmpty(contentPickerOutput.Properties); + } + + [Test] + public void OutputExpansionStrategy_CanExpandSpecificContent() + { + var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { "contentPickerTwo" }); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var contentPickerOneContent = CreateSimplePickedContent(12, 34); + var contentPickerOneProperty = CreateContentPickerProperty(content.Object, contentPickerOneContent.Key, "contentPickerOne", apiContentBuilder); + var contentPickerTwoContent = CreateSimplePickedContent(56, 78); + var contentPickerTwoProperty = CreateContentPickerProperty(content.Object, contentPickerTwoContent.Key, "contentPickerTwo", apiContentBuilder); + + SetupContentMock(content, contentPickerOneProperty, contentPickerTwoProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(2, result.Properties.Count); + + var contentPickerOneOutput = result.Properties["contentPickerOne"] as ApiContent; + Assert.IsNotNull(contentPickerOneOutput); + Assert.AreEqual(contentPickerOneContent.Key, contentPickerOneOutput.Id); + Assert.IsEmpty(contentPickerOneOutput.Properties); + + var contentPickerTwoOutput = result.Properties["contentPickerTwo"] as ApiContent; + Assert.IsNotNull(contentPickerTwoOutput); + Assert.AreEqual(contentPickerTwoContent.Key, contentPickerTwoOutput.Id); + Assert.AreEqual(2, contentPickerTwoOutput.Properties.Count); + Assert.AreEqual(56, contentPickerTwoOutput.Properties["numberOne"]); + Assert.AreEqual(78, contentPickerTwoOutput.Properties["numberTwo"]); + } + + [TestCase(false)] + [TestCase(true)] + public void OutputExpansionStrategy_CanExpandSpecificMedia(bool mediaPicker3) + { + var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { "mediaPickerTwo" }); + var apiMediaBuilder = new ApiMediaBuilder( + new ApiContentNameProvider(), + new ApiMediaUrlProvider(PublishedUrlProvider), + Mock.Of(), + accessor); + + var media = new Mock(); + + var mediaPickerOneContent = CreateSimplePickedMedia(12, 34); + var mediaPickerOneProperty = mediaPicker3 + ? CreateMediaPicker3Property(media.Object, mediaPickerOneContent.Key, "mediaPickerOne", apiMediaBuilder) + : CreateMediaPickerProperty(media.Object, mediaPickerOneContent.Key, "mediaPickerOne", apiMediaBuilder); + var mediaPickerTwoContent = CreateSimplePickedMedia(56, 78); + var mediaPickerTwoProperty = mediaPicker3 + ? CreateMediaPicker3Property(media.Object, mediaPickerTwoContent.Key, "mediaPickerTwo", apiMediaBuilder) + : CreateMediaPickerProperty(media.Object, mediaPickerTwoContent.Key, "mediaPickerTwo", apiMediaBuilder); + + SetupMediaMock(media, mediaPickerOneProperty, mediaPickerTwoProperty); + + var result = apiMediaBuilder.Build(media.Object); + + Assert.AreEqual(2, result.Properties.Count); + + var mediaPickerOneOutput = (result.Properties["mediaPickerOne"] as IEnumerable)?.FirstOrDefault(); + Assert.IsNotNull(mediaPickerOneOutput); + Assert.AreEqual(mediaPickerOneContent.Key, mediaPickerOneOutput.Id); + Assert.IsEmpty(mediaPickerOneOutput.Properties); + + var mediaPickerTwoOutput = (result.Properties["mediaPickerTwo"] as IEnumerable)?.FirstOrDefault(); + Assert.IsNotNull(mediaPickerTwoOutput); + Assert.AreEqual(mediaPickerTwoContent.Key, mediaPickerTwoOutput.Id); + Assert.AreEqual(2, mediaPickerTwoOutput.Properties.Count); + Assert.AreEqual(56, mediaPickerTwoOutput.Properties["numberOne"]); + Assert.AreEqual(78, mediaPickerTwoOutput.Properties["numberTwo"]); + } + + [Test] + public void OutputExpansionStrategy_CanExpandAllContent() + { + var accessor = CreateOutputExpansionStrategyAccessor(true); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var contentPickerOneContent = CreateSimplePickedContent(12, 34); + var contentPickerOneProperty = CreateContentPickerProperty(content.Object, contentPickerOneContent.Key, "contentPickerOne", apiContentBuilder); + var contentPickerTwoContent = CreateSimplePickedContent(56, 78); + var contentPickerTwoProperty = CreateContentPickerProperty(content.Object, contentPickerTwoContent.Key, "contentPickerTwo", apiContentBuilder); + + SetupContentMock(content, contentPickerOneProperty, contentPickerTwoProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(2, result.Properties.Count); + + var contentPickerOneOutput = result.Properties["contentPickerOne"] as ApiContent; + Assert.IsNotNull(contentPickerOneOutput); + Assert.AreEqual(contentPickerOneContent.Key, contentPickerOneOutput.Id); + Assert.AreEqual(2, contentPickerOneOutput.Properties.Count); + Assert.AreEqual(12, contentPickerOneOutput.Properties["numberOne"]); + Assert.AreEqual(34, contentPickerOneOutput.Properties["numberTwo"]); + + var contentPickerTwoOutput = result.Properties["contentPickerTwo"] as ApiContent; + Assert.IsNotNull(contentPickerTwoOutput); + Assert.AreEqual(contentPickerTwoContent.Key, contentPickerTwoOutput.Id); + Assert.AreEqual(2, contentPickerTwoOutput.Properties.Count); + Assert.AreEqual(56, contentPickerTwoOutput.Properties["numberOne"]); + Assert.AreEqual(78, contentPickerTwoOutput.Properties["numberTwo"]); + } + + [TestCase("contentPicker", "contentPicker")] + [TestCase("rootPicker", "nestedPicker")] + public void OutputExpansionStrategy_DoesNotExpandNestedContentPicker(string rootPropertyTypeAlias, string nestedPropertyTypeAlias) + { + var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { rootPropertyTypeAlias, nestedPropertyTypeAlias }); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var nestedContentPickerContent = CreateSimplePickedContent(987, 654); + var contentPickerContent = CreateMultiLevelPickedContent(123, nestedContentPickerContent, nestedPropertyTypeAlias, apiContentBuilder); + var contentPickerContentProperty = CreateContentPickerProperty(content.Object, contentPickerContent.Key, rootPropertyTypeAlias, apiContentBuilder); + + SetupContentMock(content, contentPickerContentProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(1, result.Properties.Count); + + var contentPickerOneOutput = result.Properties[rootPropertyTypeAlias] as ApiContent; + Assert.IsNotNull(contentPickerOneOutput); + Assert.AreEqual(contentPickerContent.Key, contentPickerOneOutput.Id); + Assert.AreEqual(2, contentPickerOneOutput.Properties.Count); + Assert.AreEqual(123, contentPickerOneOutput.Properties["number"]); + + var nestedContentPickerOutput = contentPickerOneOutput.Properties[nestedPropertyTypeAlias] as ApiContent; + Assert.IsNotNull(nestedContentPickerOutput); + Assert.AreEqual(nestedContentPickerContent.Key, nestedContentPickerOutput.Id); + Assert.IsEmpty(nestedContentPickerOutput.Properties); + } + + [Test] + public void OutputExpansionStrategy_DoesNotExpandElementsByDefault() + { + var accessor = CreateOutputExpansionStrategyAccessor(false); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + var apiElementBuilder = new ApiElementBuilder(accessor); + + var contentPickerValue = CreateSimplePickedContent(111, 222); + var contentPicker2Value = CreateSimplePickedContent(666, 777); + + var content = new Mock(); + SetupContentMock( + content, + CreateNumberProperty(content.Object, 444, "number"), + CreateElementProperty(content.Object, "element", 333, contentPickerValue.Key, "contentPicker", apiContentBuilder, apiElementBuilder), + CreateElementProperty(content.Object, "element2", 555, contentPicker2Value.Key, "contentPicker", apiContentBuilder, apiElementBuilder)); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(3, result.Properties.Count); + Assert.AreEqual(444, result.Properties["number"]); + + var expectedElementOutputs = new[] + { + new + { + PropertyAlias = "element", + ElementNumber = 333, + ElementContentPicker = contentPickerValue.Key + }, + new + { + PropertyAlias = "element2", + ElementNumber = 555, + ElementContentPicker = contentPicker2Value.Key + } + }; + + foreach (var expectedElementOutput in expectedElementOutputs) + { + var elementOutput = result.Properties[expectedElementOutput.PropertyAlias] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(expectedElementOutput.ElementNumber, elementOutput.Properties["number"]); + var contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(expectedElementOutput.ElementContentPicker, contentPickerOutput.Id); + Assert.AreEqual(0, contentPickerOutput.Properties.Count); + } + } + + [Test] + public void OutputExpansionStrategy_MappingContent_ThrowsOnInvalidItemType() + { + var accessor = CreateOutputExpansionStrategyAccessor(false); + if (accessor.TryGetValue(out IOutputExpansionStrategy outputExpansionStrategy) is false) + { + Assert.Fail("Could not obtain the output expansion strategy"); + } + + Assert.Throws(() => outputExpansionStrategy.MapContentProperties(PublishedMedia)); + } + + [Test] + public void OutputExpansionStrategy_MappingMedia_ThrowsOnInvalidItemType() + { + var accessor = CreateOutputExpansionStrategyAccessor(false); + if (accessor.TryGetValue(out IOutputExpansionStrategy outputExpansionStrategy) is false) + { + Assert.Fail("Could not obtain the output expansion strategy"); + } + + Assert.Throws(() => outputExpansionStrategy.MapMediaProperties(PublishedContent)); + } + + [TestCase(true)] + [TestCase(false)] + public void OutputExpansionStrategy_ForwardsExpansionStateToPropertyValueConverter(bool expanding) + { + var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { expanding ? "theAlias" : "noSuchAlias" }); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var valueConverterMock = new Mock(); + valueConverterMock.Setup(v => v.IsConverter(It.IsAny())).Returns(true); + valueConverterMock.Setup(p => p.IsValue(It.IsAny(), It.IsAny())).Returns(true); + valueConverterMock.Setup(v => v.GetPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); + valueConverterMock.Setup(v => v.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); + valueConverterMock.Setup(v => v.GetDeliveryApiPropertyCacheLevelForExpansion(It.IsAny())).Returns(PropertyCacheLevel.None); + valueConverterMock.Setup(v => v.ConvertIntermediateToDeliveryApiObject( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(expanding ? "Expanding" : "Not expanding"); + + var propertyType = SetupPublishedPropertyType(valueConverterMock.Object, "theAlias", Constants.PropertyEditors.Aliases.Label); + var property = new PublishedElementPropertyBase(propertyType, content.Object, false, PropertyCacheLevel.None, "The Value"); + + SetupContentMock(content, property); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(1, result.Properties.Count); + Assert.AreEqual(expanding ? "Expanding" : "Not expanding", result.Properties["theAlias"] as string); + } + + protected abstract IOutputExpansionStrategyAccessor CreateOutputExpansionStrategyAccessor(string? expand = null, string? fields = null); + + protected IOutputExpansionStrategyAccessor CreateOutputExpansionStrategyAccessor(bool expandAll = false, string[]? expandPropertyAliases = null) + => CreateOutputExpansionStrategyAccessor(FormatExpandSyntax(expandAll, expandPropertyAliases)); + + protected abstract string? FormatExpandSyntax(bool expandAll = false, string[]? expandPropertyAliases = null); + + protected void SetupContentMock(Mock content, params IPublishedProperty[] properties) + { + var key = Guid.NewGuid(); + var name = "The page"; + var urlSegment = "url-segment"; + ConfigurePublishedContentMock(content, key, name, urlSegment, _contentType, properties); + + RegisterContentWithProviders(content.Object); + } + + protected void SetupMediaMock(Mock media, params IPublishedProperty[] properties) + { + var key = Guid.NewGuid(); + var name = "The media"; + var urlSegment = "media-url-segment"; + ConfigurePublishedContentMock(media, key, name, urlSegment, _mediaType, properties); + + RegisterMediaWithProviders(media.Object); + } + + protected IPublishedContent CreateSimplePickedContent(int numberOneValue, int numberTwoValue) + { + var content = new Mock(); + SetupContentMock( + content, + CreateNumberProperty(content.Object, numberOneValue, "numberOne"), + CreateNumberProperty(content.Object, numberTwoValue, "numberTwo")); + + return content.Object; + } + + protected IPublishedContent CreateSimplePickedMedia(int numberOneValue, int numberTwoValue) + { + var media = new Mock(); + SetupMediaMock( + media, + CreateNumberProperty(media.Object, numberOneValue, "numberOne"), + CreateNumberProperty(media.Object, numberTwoValue, "numberTwo")); + + return media.Object; + } + + protected IPublishedContent CreateMultiLevelPickedContent(int numberValue, IPublishedContent nestedContentPickerValue, string nestedContentPickerPropertyTypeAlias, ApiContentBuilder apiContentBuilder) + { + var content = new Mock(); + SetupContentMock( + content, + CreateNumberProperty(content.Object, numberValue, "number"), + CreateContentPickerProperty(content.Object, nestedContentPickerValue.Key, nestedContentPickerPropertyTypeAlias, apiContentBuilder)); + + return content.Object; + } + + internal PublishedElementPropertyBase CreateContentPickerProperty(IPublishedElement parent, Guid pickedContentKey, string propertyTypeAlias, IApiContentBuilder contentBuilder) + { + ContentPickerValueConverter contentPickerValueConverter = new ContentPickerValueConverter(PublishedSnapshotAccessor, contentBuilder); + var contentPickerPropertyType = SetupPublishedPropertyType(contentPickerValueConverter, propertyTypeAlias, Constants.PropertyEditors.Aliases.ContentPicker); + + return new PublishedElementPropertyBase(contentPickerPropertyType, parent, false, PropertyCacheLevel.None, new GuidUdi(Constants.UdiEntityType.Document, pickedContentKey).ToString()); + } + + internal PublishedElementPropertyBase CreateMediaPickerProperty(IPublishedElement parent, Guid pickedMediaKey, string propertyTypeAlias, IApiMediaBuilder mediaBuilder) + { + MediaPickerValueConverter mediaPickerValueConverter = new MediaPickerValueConverter(PublishedSnapshotAccessor, Mock.Of(), mediaBuilder); + var mediaPickerPropertyType = SetupPublishedPropertyType(mediaPickerValueConverter, propertyTypeAlias, Constants.PropertyEditors.Aliases.MediaPicker, new MediaPickerConfiguration()); + + return new PublishedElementPropertyBase(mediaPickerPropertyType, parent, false, PropertyCacheLevel.None, new GuidUdi(Constants.UdiEntityType.Media, pickedMediaKey).ToString()); + } + + internal PublishedElementPropertyBase CreateMediaPicker3Property(IPublishedElement parent, Guid pickedMediaKey, string propertyTypeAlias, IApiMediaBuilder mediaBuilder) + { + var serializer = new JsonNetSerializer(); + var value = serializer.Serialize(new[] + { + new MediaPicker3PropertyEditor.MediaPicker3PropertyValueEditor.MediaWithCropsDto + { + MediaKey = pickedMediaKey + } + }); + + var publishedValueFallback = Mock.Of(); + var apiMediaWithCropsBuilder = new ApiMediaWithCropsBuilder(mediaBuilder, publishedValueFallback); + + MediaPickerWithCropsValueConverter mediaPickerValueConverter = new MediaPickerWithCropsValueConverter(PublishedSnapshotAccessor, PublishedUrlProvider, publishedValueFallback, new JsonNetSerializer(), apiMediaWithCropsBuilder); + var mediaPickerPropertyType = SetupPublishedPropertyType(mediaPickerValueConverter, propertyTypeAlias, Constants.PropertyEditors.Aliases.MediaPicker3, new MediaPicker3Configuration()); + + return new PublishedElementPropertyBase(mediaPickerPropertyType, parent, false, PropertyCacheLevel.None, value); + } + + internal PublishedElementPropertyBase CreateNumberProperty(IPublishedElement parent, int propertyValue, string propertyTypeAlias) + { + var numberPropertyType = SetupPublishedPropertyType(new IntegerValueConverter(), propertyTypeAlias, Constants.PropertyEditors.Aliases.Label); + return new PublishedElementPropertyBase(numberPropertyType, parent, false, PropertyCacheLevel.None, propertyValue); + } + + internal PublishedElementPropertyBase CreateElementProperty( + IPublishedElement parent, + string elementPropertyAlias, + int numberPropertyValue, + Guid contentPickerPropertyValue, + string contentPickerPropertyTypeAlias, + IApiContentBuilder apiContentBuilder, + IApiElementBuilder apiElementBuilder) + { + var element = new Mock(); + element.SetupGet(c => c.ContentType).Returns(_elementType); + element.SetupGet(c => c.Properties).Returns(new[] + { + CreateNumberProperty(element.Object, numberPropertyValue, "number"), + CreateContentPickerProperty(element.Object, contentPickerPropertyValue, contentPickerPropertyTypeAlias, apiContentBuilder) + }); + + var elementValueConverter = new Mock(); + elementValueConverter + .Setup(p => p.ConvertIntermediateToDeliveryApiObject( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(() => apiElementBuilder.Build(element.Object)); + elementValueConverter.Setup(p => p.IsConverter(It.IsAny())).Returns(true); + elementValueConverter.Setup(p => p.IsValue(It.IsAny(), It.IsAny())).Returns(true); + elementValueConverter.Setup(p => p.GetPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); + elementValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); + elementValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevelForExpansion(It.IsAny())).Returns(PropertyCacheLevel.None); + + var elementPropertyType = SetupPublishedPropertyType(elementValueConverter.Object, elementPropertyAlias, "My.Element.Property"); + return new PublishedElementPropertyBase(elementPropertyType, parent, false, PropertyCacheLevel.None); + } + + protected IApiContentRouteBuilder ApiContentRouteBuilder() => CreateContentRouteBuilder(PublishedUrlProvider, CreateGlobalSettings()); +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs index 75612932c8..34258a362f 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyTests.cs @@ -3,258 +3,19 @@ using Microsoft.Extensions.Primitives; using Moq; using NUnit.Framework; using Umbraco.Cms.Api.Delivery.Rendering; -using Umbraco.Cms.Core; using Umbraco.Cms.Core.DeliveryApi; using Umbraco.Cms.Core.Models.DeliveryApi; using Umbraco.Cms.Core.Models.PublishedContent; -using Umbraco.Cms.Core.PropertyEditors; -using Umbraco.Cms.Core.PropertyEditors.DeliveryApi; -using Umbraco.Cms.Core.PropertyEditors.ValueConverters; -using Umbraco.Cms.Core.PublishedCache; -using Umbraco.Cms.Infrastructure.DeliveryApi; -using Umbraco.Cms.Infrastructure.Serialization; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; +/// +/// Any tests contained within this class specifically test property expansion V1 and not V2. If the aim is to test both +/// versions, please put the tests in the base class. +/// [TestFixture] -public class OutputExpansionStrategyTests : PropertyValueConverterTests +public class OutputExpansionStrategyTests : OutputExpansionStrategyTestBase { - private IPublishedContentType _contentType; - private IPublishedContentType _elementType; - private IPublishedContentType _mediaType; - - [SetUp] - public void SetUp() - { - var contentType = new Mock(); - contentType.SetupGet(c => c.Alias).Returns("thePageType"); - contentType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Content); - _contentType = contentType.Object; - var elementType = new Mock(); - elementType.SetupGet(c => c.Alias).Returns("theElementType"); - elementType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Element); - _elementType = elementType.Object; - var mediaType = new Mock(); - mediaType.SetupGet(c => c.Alias).Returns("theMediaType"); - mediaType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Media); - _mediaType = mediaType.Object; - } - - [Test] - public void OutputExpansionStrategy_ExpandsNothingByDefault() - { - var accessor = CreateOutputExpansionStrategyAccessor(); - var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); - - var content = new Mock(); - var prop1 = new PublishedElementPropertyBase(DeliveryApiPropertyType, content.Object, false, PropertyCacheLevel.None); - var prop2 = new PublishedElementPropertyBase(DefaultPropertyType, content.Object, false, PropertyCacheLevel.None); - - var contentPickerContent = CreateSimplePickedContent(123, 456); - var contentPickerProperty = CreateContentPickerProperty(content.Object, contentPickerContent.Key, "contentPicker", apiContentBuilder); - - SetupContentMock(content, prop1, prop2, contentPickerProperty); - - var result = apiContentBuilder.Build(content.Object); - - Assert.AreEqual(3, result.Properties.Count); - Assert.AreEqual("Delivery API value", result.Properties[DeliveryApiPropertyType.Alias]); - Assert.AreEqual("Default value", result.Properties[DefaultPropertyType.Alias]); - var contentPickerOutput = result.Properties["contentPicker"] as ApiContent; - Assert.IsNotNull(contentPickerOutput); - Assert.AreEqual(contentPickerContent.Key, contentPickerOutput.Id); - Assert.IsEmpty(contentPickerOutput.Properties); - } - - [Test] - public void OutputExpansionStrategy_CanExpandSpecificContent() - { - var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { "contentPickerTwo" }); - var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); - - var content = new Mock(); - - var contentPickerOneContent = CreateSimplePickedContent(12, 34); - var contentPickerOneProperty = CreateContentPickerProperty(content.Object, contentPickerOneContent.Key, "contentPickerOne", apiContentBuilder); - var contentPickerTwoContent = CreateSimplePickedContent(56, 78); - var contentPickerTwoProperty = CreateContentPickerProperty(content.Object, contentPickerTwoContent.Key, "contentPickerTwo", apiContentBuilder); - - SetupContentMock(content, contentPickerOneProperty, contentPickerTwoProperty); - - var result = apiContentBuilder.Build(content.Object); - - Assert.AreEqual(2, result.Properties.Count); - - var contentPickerOneOutput = result.Properties["contentPickerOne"] as ApiContent; - Assert.IsNotNull(contentPickerOneOutput); - Assert.AreEqual(contentPickerOneContent.Key, contentPickerOneOutput.Id); - Assert.IsEmpty(contentPickerOneOutput.Properties); - - var contentPickerTwoOutput = result.Properties["contentPickerTwo"] as ApiContent; - Assert.IsNotNull(contentPickerTwoOutput); - Assert.AreEqual(contentPickerTwoContent.Key, contentPickerTwoOutput.Id); - Assert.AreEqual(2, contentPickerTwoOutput.Properties.Count); - Assert.AreEqual(56, contentPickerTwoOutput.Properties["numberOne"]); - Assert.AreEqual(78, contentPickerTwoOutput.Properties["numberTwo"]); - } - - [TestCase(false)] - [TestCase(true)] - public void OutputExpansionStrategy_CanExpandSpecificMedia(bool mediaPicker3) - { - var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { "mediaPickerTwo" }); - var apiMediaBuilder = new ApiMediaBuilder( - new ApiContentNameProvider(), - new ApiMediaUrlProvider(PublishedUrlProvider), - Mock.Of(), - accessor); - - var media = new Mock(); - - var mediaPickerOneContent = CreateSimplePickedMedia(12, 34); - var mediaPickerOneProperty = mediaPicker3 - ? CreateMediaPicker3Property(media.Object, mediaPickerOneContent.Key, "mediaPickerOne", apiMediaBuilder) - : CreateMediaPickerProperty(media.Object, mediaPickerOneContent.Key, "mediaPickerOne", apiMediaBuilder); - var mediaPickerTwoContent = CreateSimplePickedMedia(56, 78); - var mediaPickerTwoProperty = mediaPicker3 - ? CreateMediaPicker3Property(media.Object, mediaPickerTwoContent.Key, "mediaPickerTwo", apiMediaBuilder) - : CreateMediaPickerProperty(media.Object, mediaPickerTwoContent.Key, "mediaPickerTwo", apiMediaBuilder); - - SetupMediaMock(media, mediaPickerOneProperty, mediaPickerTwoProperty); - - var result = apiMediaBuilder.Build(media.Object); - - Assert.AreEqual(2, result.Properties.Count); - - var mediaPickerOneOutput = (result.Properties["mediaPickerOne"] as IEnumerable)?.FirstOrDefault(); - Assert.IsNotNull(mediaPickerOneOutput); - Assert.AreEqual(mediaPickerOneContent.Key, mediaPickerOneOutput.Id); - Assert.IsEmpty(mediaPickerOneOutput.Properties); - - var mediaPickerTwoOutput = (result.Properties["mediaPickerTwo"] as IEnumerable)?.FirstOrDefault(); - Assert.IsNotNull(mediaPickerTwoOutput); - Assert.AreEqual(mediaPickerTwoContent.Key, mediaPickerTwoOutput.Id); - Assert.AreEqual(2, mediaPickerTwoOutput.Properties.Count); - Assert.AreEqual(56, mediaPickerTwoOutput.Properties["numberOne"]); - Assert.AreEqual(78, mediaPickerTwoOutput.Properties["numberTwo"]); - } - - [Test] - public void OutputExpansionStrategy_CanExpandAllContent() - { - var accessor = CreateOutputExpansionStrategyAccessor(true); - var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); - - var content = new Mock(); - - var contentPickerOneContent = CreateSimplePickedContent(12, 34); - var contentPickerOneProperty = CreateContentPickerProperty(content.Object, contentPickerOneContent.Key, "contentPickerOne", apiContentBuilder); - var contentPickerTwoContent = CreateSimplePickedContent(56, 78); - var contentPickerTwoProperty = CreateContentPickerProperty(content.Object, contentPickerTwoContent.Key, "contentPickerTwo", apiContentBuilder); - - SetupContentMock(content, contentPickerOneProperty, contentPickerTwoProperty); - - var result = apiContentBuilder.Build(content.Object); - - Assert.AreEqual(2, result.Properties.Count); - - var contentPickerOneOutput = result.Properties["contentPickerOne"] as ApiContent; - Assert.IsNotNull(contentPickerOneOutput); - Assert.AreEqual(contentPickerOneContent.Key, contentPickerOneOutput.Id); - Assert.AreEqual(2, contentPickerOneOutput.Properties.Count); - Assert.AreEqual(12, contentPickerOneOutput.Properties["numberOne"]); - Assert.AreEqual(34, contentPickerOneOutput.Properties["numberTwo"]); - - var contentPickerTwoOutput = result.Properties["contentPickerTwo"] as ApiContent; - Assert.IsNotNull(contentPickerTwoOutput); - Assert.AreEqual(contentPickerTwoContent.Key, contentPickerTwoOutput.Id); - Assert.AreEqual(2, contentPickerTwoOutput.Properties.Count); - Assert.AreEqual(56, contentPickerTwoOutput.Properties["numberOne"]); - Assert.AreEqual(78, contentPickerTwoOutput.Properties["numberTwo"]); - } - - [TestCase("contentPicker", "contentPicker")] - [TestCase("rootPicker", "nestedPicker")] - public void OutputExpansionStrategy_DoesNotExpandNestedContentPicker(string rootPropertyTypeAlias, string nestedPropertyTypeAlias) - { - var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { rootPropertyTypeAlias, nestedPropertyTypeAlias }); - var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); - - var content = new Mock(); - - var nestedContentPickerContent = CreateSimplePickedContent(987, 654); - var contentPickerContent = CreateMultiLevelPickedContent(123, nestedContentPickerContent, nestedPropertyTypeAlias, apiContentBuilder); - var contentPickerContentProperty = CreateContentPickerProperty(content.Object, contentPickerContent.Key, rootPropertyTypeAlias, apiContentBuilder); - - SetupContentMock(content, contentPickerContentProperty); - - var result = apiContentBuilder.Build(content.Object); - - Assert.AreEqual(1, result.Properties.Count); - - var contentPickerOneOutput = result.Properties[rootPropertyTypeAlias] as ApiContent; - Assert.IsNotNull(contentPickerOneOutput); - Assert.AreEqual(contentPickerContent.Key, contentPickerOneOutput.Id); - Assert.AreEqual(2, contentPickerOneOutput.Properties.Count); - Assert.AreEqual(123, contentPickerOneOutput.Properties["number"]); - - var nestedContentPickerOutput = contentPickerOneOutput.Properties[nestedPropertyTypeAlias] as ApiContent; - Assert.IsNotNull(nestedContentPickerOutput); - Assert.AreEqual(nestedContentPickerContent.Key, nestedContentPickerOutput.Id); - Assert.IsEmpty(nestedContentPickerOutput.Properties); - } - - [Test] - public void OutputExpansionStrategy_DoesNotExpandElementsByDefault() - { - var accessor = CreateOutputExpansionStrategyAccessor(); - var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); - var apiElementBuilder = new ApiElementBuilder(accessor); - - var contentPickerValue = CreateSimplePickedContent(111, 222); - var contentPicker2Value = CreateSimplePickedContent(666, 777); - - var content = new Mock(); - SetupContentMock( - content, - CreateNumberProperty(content.Object, 444, "number"), - CreateElementProperty(content.Object, "element", 333, contentPickerValue.Key, "contentPicker", apiContentBuilder, apiElementBuilder), - CreateElementProperty(content.Object, "element2", 555, contentPicker2Value.Key, "contentPicker", apiContentBuilder, apiElementBuilder)); - - var result = apiContentBuilder.Build(content.Object); - - Assert.AreEqual(3, result.Properties.Count); - Assert.AreEqual(444, result.Properties["number"]); - - var expectedElementOutputs = new[] - { - new - { - PropertyAlias = "element", - ElementNumber = 333, - ElementContentPicker = contentPickerValue.Key - }, - new - { - PropertyAlias = "element2", - ElementNumber = 555, - ElementContentPicker = contentPicker2Value.Key - } - }; - - foreach (var expectedElementOutput in expectedElementOutputs) - { - var elementOutput = result.Properties[expectedElementOutput.PropertyAlias] as IApiElement; - Assert.IsNotNull(elementOutput); - Assert.AreEqual(2, elementOutput.Properties.Count); - Assert.AreEqual(expectedElementOutput.ElementNumber, elementOutput.Properties["number"]); - var contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; - Assert.IsNotNull(contentPickerOutput); - Assert.AreEqual(expectedElementOutput.ElementContentPicker, contentPickerOutput.Id); - Assert.AreEqual(0, contentPickerOutput.Properties.Count); - } - } - [Test] public void OutputExpansionStrategy_CanExpandSpecifiedElement() { @@ -387,71 +148,12 @@ public class OutputExpansionStrategyTests : PropertyValueConverterTests Assert.AreEqual(0, nestedContentPickerOutput.Properties.Count); } - [Test] - public void OutputExpansionStrategy_MappingContent_ThrowsOnInvalidItemType() - { - var accessor = CreateOutputExpansionStrategyAccessor(); - if (accessor.TryGetValue(out IOutputExpansionStrategy outputExpansionStrategy) is false) - { - Assert.Fail("Could not obtain the output expansion strategy"); - } - - Assert.Throws(() => outputExpansionStrategy.MapContentProperties(PublishedMedia)); - } - - [Test] - public void OutputExpansionStrategy_MappingMedia_ThrowsOnInvalidItemType() - { - var accessor = CreateOutputExpansionStrategyAccessor(); - if (accessor.TryGetValue(out IOutputExpansionStrategy outputExpansionStrategy) is false) - { - Assert.Fail("Could not obtain the output expansion strategy"); - } - - Assert.Throws(() => outputExpansionStrategy.MapMediaProperties(PublishedContent)); - } - - [TestCase(true)] - [TestCase(false)] - public void OutputExpansionStrategy_ForwardsExpansionStateToPropertyValueConverter(bool expanding) - { - var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { expanding ? "theAlias" : "noSuchAlias" }); - var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); - - var content = new Mock(); - - var valueConverterMock = new Mock(); - valueConverterMock.Setup(v => v.IsConverter(It.IsAny())).Returns(true); - valueConverterMock.Setup(p => p.IsValue(It.IsAny(), It.IsAny())).Returns(true); - valueConverterMock.Setup(v => v.GetPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); - valueConverterMock.Setup(v => v.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); - valueConverterMock.Setup(v => v.ConvertIntermediateToDeliveryApiObject( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(expanding ? "Expanding" : "Not expanding"); - - var propertyType = SetupPublishedPropertyType(valueConverterMock.Object, "theAlias", Constants.PropertyEditors.Aliases.Label); - var property = new PublishedElementPropertyBase(propertyType, content.Object, false, PropertyCacheLevel.None, "The Value"); - - SetupContentMock(content, property); - - var result = apiContentBuilder.Build(content.Object); - - Assert.AreEqual(1, result.Properties.Count); - Assert.AreEqual(expanding ? "Expanding" : "Not expanding", result.Properties["theAlias"] as string); - } - - private IOutputExpansionStrategyAccessor CreateOutputExpansionStrategyAccessor(bool expandAll = false, string[]? expandPropertyAliases = null) + protected override IOutputExpansionStrategyAccessor CreateOutputExpansionStrategyAccessor(string? expand = null, string? fields = null) { var httpContextMock = new Mock(); var httpRequestMock = new Mock(); var httpContextAccessorMock = new Mock(); - var expand = expandAll ? "all" : expandPropertyAliases != null ? $"property:{string.Join(",", expandPropertyAliases)}" : null; httpRequestMock .SetupGet(r => r.Query) .Returns(new QueryCollection(new Dictionary { { "expand", expand } })); @@ -466,136 +168,6 @@ public class OutputExpansionStrategyTests : PropertyValueConverterTests return outputExpansionStrategyAccessorMock.Object; } - private void SetupContentMock(Mock content, params IPublishedProperty[] properties) - { - var key = Guid.NewGuid(); - var name = "The page"; - var urlSegment = "url-segment"; - ConfigurePublishedContentMock(content, key, name, urlSegment, _contentType, properties); - - RegisterContentWithProviders(content.Object); - } - - private void SetupMediaMock(Mock media, params IPublishedProperty[] properties) - { - var key = Guid.NewGuid(); - var name = "The media"; - var urlSegment = "media-url-segment"; - ConfigurePublishedContentMock(media, key, name, urlSegment, _mediaType, properties); - - RegisterMediaWithProviders(media.Object); - } - - private IPublishedContent CreateSimplePickedContent(int numberOneValue, int numberTwoValue) - { - var content = new Mock(); - SetupContentMock( - content, - CreateNumberProperty(content.Object, numberOneValue, "numberOne"), - CreateNumberProperty(content.Object, numberTwoValue, "numberTwo")); - - return content.Object; - } - - private IPublishedContent CreateSimplePickedMedia(int numberOneValue, int numberTwoValue) - { - var media = new Mock(); - SetupMediaMock( - media, - CreateNumberProperty(media.Object, numberOneValue, "numberOne"), - CreateNumberProperty(media.Object, numberTwoValue, "numberTwo")); - - return media.Object; - } - - private IPublishedContent CreateMultiLevelPickedContent(int numberValue, IPublishedContent nestedContentPickerValue, string nestedContentPickerPropertyTypeAlias, ApiContentBuilder apiContentBuilder) - { - var content = new Mock(); - SetupContentMock( - content, - CreateNumberProperty(content.Object, numberValue, "number"), - CreateContentPickerProperty(content.Object, nestedContentPickerValue.Key, nestedContentPickerPropertyTypeAlias, apiContentBuilder)); - - return content.Object; - } - - private PublishedElementPropertyBase CreateContentPickerProperty(IPublishedElement parent, Guid pickedContentKey, string propertyTypeAlias, IApiContentBuilder contentBuilder) - { - ContentPickerValueConverter contentPickerValueConverter = new ContentPickerValueConverter(PublishedSnapshotAccessor, contentBuilder); - var contentPickerPropertyType = SetupPublishedPropertyType(contentPickerValueConverter, propertyTypeAlias, Constants.PropertyEditors.Aliases.ContentPicker); - - return new PublishedElementPropertyBase(contentPickerPropertyType, parent, false, PropertyCacheLevel.None, new GuidUdi(Constants.UdiEntityType.Document, pickedContentKey).ToString()); - } - - private PublishedElementPropertyBase CreateMediaPickerProperty(IPublishedElement parent, Guid pickedMediaKey, string propertyTypeAlias, IApiMediaBuilder mediaBuilder) - { - MediaPickerValueConverter mediaPickerValueConverter = new MediaPickerValueConverter(PublishedSnapshotAccessor, Mock.Of(), mediaBuilder); - var mediaPickerPropertyType = SetupPublishedPropertyType(mediaPickerValueConverter, propertyTypeAlias, Constants.PropertyEditors.Aliases.MediaPicker, new MediaPickerConfiguration()); - - return new PublishedElementPropertyBase(mediaPickerPropertyType, parent, false, PropertyCacheLevel.None, new GuidUdi(Constants.UdiEntityType.Media, pickedMediaKey).ToString()); - } - - private PublishedElementPropertyBase CreateMediaPicker3Property(IPublishedElement parent, Guid pickedMediaKey, string propertyTypeAlias, IApiMediaBuilder mediaBuilder) - { - var serializer = new JsonNetSerializer(); - var value = serializer.Serialize(new[] - { - new MediaPicker3PropertyEditor.MediaPicker3PropertyValueEditor.MediaWithCropsDto - { - MediaKey = pickedMediaKey - } - }); - - var publishedValueFallback = Mock.Of(); - var apiMediaWithCropsBuilder = new ApiMediaWithCropsBuilder(mediaBuilder, publishedValueFallback); - - MediaPickerWithCropsValueConverter mediaPickerValueConverter = new MediaPickerWithCropsValueConverter(PublishedSnapshotAccessor, PublishedUrlProvider, publishedValueFallback, new JsonNetSerializer(), apiMediaWithCropsBuilder); - var mediaPickerPropertyType = SetupPublishedPropertyType(mediaPickerValueConverter, propertyTypeAlias, Constants.PropertyEditors.Aliases.MediaPicker3, new MediaPicker3Configuration()); - - return new PublishedElementPropertyBase(mediaPickerPropertyType, parent, false, PropertyCacheLevel.None, value); - } - - private PublishedElementPropertyBase CreateNumberProperty(IPublishedElement parent, int propertyValue, string propertyTypeAlias) - { - var numberPropertyType = SetupPublishedPropertyType(new IntegerValueConverter(), propertyTypeAlias, Constants.PropertyEditors.Aliases.Label); - return new PublishedElementPropertyBase(numberPropertyType, parent, false, PropertyCacheLevel.None, propertyValue); - } - - private PublishedElementPropertyBase CreateElementProperty( - IPublishedElement parent, - string elementPropertyAlias, - int numberPropertyValue, - Guid contentPickerPropertyValue, - string contentPickerPropertyTypeAlias, - IApiContentBuilder apiContentBuilder, - IApiElementBuilder apiElementBuilder) - { - var element = new Mock(); - element.SetupGet(c => c.ContentType).Returns(_elementType); - element.SetupGet(c => c.Properties).Returns(new[] - { - CreateNumberProperty(element.Object, numberPropertyValue, "number"), - CreateContentPickerProperty(element.Object, contentPickerPropertyValue, contentPickerPropertyTypeAlias, apiContentBuilder) - }); - - var elementValueConverter = new Mock(); - elementValueConverter - .Setup(p => p.ConvertIntermediateToDeliveryApiObject( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Returns(() => apiElementBuilder.Build(element.Object)); - elementValueConverter.Setup(p => p.IsConverter(It.IsAny())).Returns(true); - elementValueConverter.Setup(p => p.IsValue(It.IsAny(), It.IsAny())).Returns(true); - elementValueConverter.Setup(p => p.GetPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); - elementValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevel(It.IsAny())).Returns(PropertyCacheLevel.None); - - var elementPropertyType = SetupPublishedPropertyType(elementValueConverter.Object, elementPropertyAlias, "My.Element.Property"); - return new PublishedElementPropertyBase(elementPropertyType, parent, false, PropertyCacheLevel.None); - } - - private IApiContentRouteBuilder ApiContentRouteBuilder() => CreateContentRouteBuilder(PublishedUrlProvider, CreateGlobalSettings()); + protected override string? FormatExpandSyntax(bool expandAll = false, string[]? expandPropertyAliases = null) + => expandAll ? "all" : expandPropertyAliases?.Any() is true ? $"property:{string.Join(",", expandPropertyAliases)}" : null; } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyV2Tests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyV2Tests.cs new file mode 100644 index 0000000000..7619d1055a --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/OutputExpansionStrategyV2Tests.cs @@ -0,0 +1,355 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Api.Delivery.Rendering; +using Umbraco.Cms.Core.DeliveryApi; +using Umbraco.Cms.Core.Models.DeliveryApi; +using Umbraco.Cms.Core.Models.PublishedContent; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.DeliveryApi; + +/// +/// Any tests contained within this class specifically test property expansion V2 (and field limiting) - not V1. If the +/// aim is to test expansion for both versions, please put the tests in the base class. +/// +[TestFixture] +public class OutputExpansionStrategyV2Tests : OutputExpansionStrategyTestBase +{ + [TestCase("contentPicker", "contentPicker")] + [TestCase("rootPicker", "nestedPicker")] + public void OutputExpansionStrategy_CanExpandNestedContentPicker(string rootPropertyTypeAlias, string nestedPropertyTypeAlias) + { + var accessor = CreateOutputExpansionStrategyAccessor($"properties[{rootPropertyTypeAlias}[properties[{nestedPropertyTypeAlias}]]]"); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var nestedContentPickerContent = CreateSimplePickedContent(987, 654); + var contentPickerContent = CreateMultiLevelPickedContent(123, nestedContentPickerContent, nestedPropertyTypeAlias, apiContentBuilder); + var contentPickerContentProperty = CreateContentPickerProperty(content.Object, contentPickerContent.Key, rootPropertyTypeAlias, apiContentBuilder); + + SetupContentMock(content, contentPickerContentProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(1, result.Properties.Count); + + var contentPickerOneOutput = result.Properties[rootPropertyTypeAlias] as ApiContent; + Assert.IsNotNull(contentPickerOneOutput); + Assert.AreEqual(contentPickerContent.Key, contentPickerOneOutput.Id); + Assert.AreEqual(2, contentPickerOneOutput.Properties.Count); + Assert.AreEqual(123, contentPickerOneOutput.Properties["number"]); + + var nestedContentPickerOutput = contentPickerOneOutput.Properties[nestedPropertyTypeAlias] as ApiContent; + Assert.IsNotNull(nestedContentPickerOutput); + Assert.AreEqual(nestedContentPickerContent.Key, nestedContentPickerOutput.Id); + Assert.IsNotEmpty(nestedContentPickerOutput.Properties); + Assert.AreEqual(987, nestedContentPickerOutput.Properties["numberOne"]); + Assert.AreEqual(654, nestedContentPickerOutput.Properties["numberTwo"]); + } + + [Test] + public void OutputExpansionStrategy_CanExpandSpecifiedElement() + { + // var accessor = CreateOutputExpansionStrategyAccessor(false, new[] { "element" }); + var accessor = CreateOutputExpansionStrategyAccessor("properties[element[properties[$all]]]"); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + var apiElementBuilder = new ApiElementBuilder(accessor); + + var contentPickerValue = CreateSimplePickedContent(111, 222); + var contentPicker2Value = CreateSimplePickedContent(666, 777); + + var content = new Mock(); + SetupContentMock( + content, + CreateNumberProperty(content.Object, 444, "number"), + CreateElementProperty(content.Object, "element", 333, contentPickerValue.Key, "contentPicker", apiContentBuilder, apiElementBuilder), + CreateElementProperty(content.Object, "element2", 555, contentPicker2Value.Key, "contentPicker", apiContentBuilder, apiElementBuilder)); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(3, result.Properties.Count); + Assert.AreEqual(444, result.Properties["number"]); + + var elementOutput = result.Properties["element"] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(333, elementOutput.Properties["number"]); + var contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(contentPickerValue.Key, contentPickerOutput.Id); + Assert.AreEqual(2, contentPickerOutput.Properties.Count); + Assert.AreEqual(111, contentPickerOutput.Properties["numberOne"]); + Assert.AreEqual(222, contentPickerOutput.Properties["numberTwo"]); + + elementOutput = result.Properties["element2"] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(555, elementOutput.Properties["number"]); + contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(contentPicker2Value.Key, contentPickerOutput.Id); + Assert.AreEqual(0, contentPickerOutput.Properties.Count); + } + + [Test] + public void OutputExpansionStrategy_CanExpandAllElements() + { + var accessor = CreateOutputExpansionStrategyAccessor("properties[element[properties[$all]],element2[properties[$all]]]" ); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + var apiElementBuilder = new ApiElementBuilder(accessor); + + var contentPickerValue = CreateSimplePickedContent(111, 222); + var contentPicker2Value = CreateSimplePickedContent(666, 777); + + var content = new Mock(); + SetupContentMock( + content, + CreateNumberProperty(content.Object, 444, "number"), + CreateElementProperty(content.Object, "element", 333, contentPickerValue.Key, "contentPicker", apiContentBuilder, apiElementBuilder), + CreateElementProperty(content.Object, "element2", 555, contentPicker2Value.Key, "contentPicker", apiContentBuilder, apiElementBuilder)); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(3, result.Properties.Count); + Assert.AreEqual(444, result.Properties["number"]); + + var expectedElementOutputs = new[] + { + new + { + PropertyAlias = "element", + ElementNumber = 333, + ElementContentPicker = contentPickerValue.Key, + ContentNumberOne = 111, + ContentNumberTwo = 222 + }, + new + { + PropertyAlias = "element2", + ElementNumber = 555, + ElementContentPicker = contentPicker2Value.Key, + ContentNumberOne = 666, + ContentNumberTwo = 777 + } + }; + + foreach (var expectedElementOutput in expectedElementOutputs) + { + var elementOutput = result.Properties[expectedElementOutput.PropertyAlias] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(expectedElementOutput.ElementNumber, elementOutput.Properties["number"]); + var contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(expectedElementOutput.ElementContentPicker, contentPickerOutput.Id); + Assert.AreEqual(2, contentPickerOutput.Properties.Count); + Assert.AreEqual(expectedElementOutput.ContentNumberOne, contentPickerOutput.Properties["numberOne"]); + Assert.AreEqual(expectedElementOutput.ContentNumberTwo, contentPickerOutput.Properties["numberTwo"]); + } + } + + [Test] + public void OutputExpansionStrategy_DoesNotExpandElementNestedContentPicker() + { + var accessor = CreateOutputExpansionStrategyAccessor("properties[element[properties[contentPicker]]]" ); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + var apiElementBuilder = new ApiElementBuilder(accessor); + + var nestedContentPickerValue = CreateSimplePickedContent(111, 222); + var contentPickerValue = CreateMultiLevelPickedContent(987, nestedContentPickerValue, "contentPicker", apiContentBuilder); + + var content = new Mock(); + SetupContentMock(content, CreateElementProperty(content.Object, "element", 333, contentPickerValue.Key, "contentPicker", apiContentBuilder, apiElementBuilder)); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(1, result.Properties.Count); + + var elementOutput = result.Properties["element"] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(333, elementOutput.Properties["number"]); + var contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(contentPickerValue.Key, contentPickerOutput.Id); + Assert.AreEqual(2, contentPickerOutput.Properties.Count); + Assert.AreEqual(987, contentPickerOutput.Properties["number"]); + var nestedContentPickerOutput = contentPickerOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(nestedContentPickerOutput); + Assert.AreEqual(nestedContentPickerValue.Key, nestedContentPickerOutput.Id); + Assert.AreEqual(0, nestedContentPickerOutput.Properties.Count); + } + + [Test] + public void OutputExpansionStrategy_CanExpandElementNestedContentPicker() + { + var accessor = CreateOutputExpansionStrategyAccessor("properties[element[properties[contentPicker[properties[nestedContentPicker]]]]]"); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + var apiElementBuilder = new ApiElementBuilder(accessor); + + var nestedContentPickerValue = CreateSimplePickedContent(111, 222); + var contentPickerValue = CreateMultiLevelPickedContent(987, nestedContentPickerValue, "nestedContentPicker", apiContentBuilder); + + var content = new Mock(); + SetupContentMock(content, CreateElementProperty(content.Object, "element", 333, contentPickerValue.Key, "contentPicker", apiContentBuilder, apiElementBuilder)); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(1, result.Properties.Count); + + var elementOutput = result.Properties["element"] as IApiElement; + Assert.IsNotNull(elementOutput); + Assert.AreEqual(2, elementOutput.Properties.Count); + Assert.AreEqual(333, elementOutput.Properties["number"]); + var contentPickerOutput = elementOutput.Properties["contentPicker"] as IApiContent; + Assert.IsNotNull(contentPickerOutput); + Assert.AreEqual(contentPickerValue.Key, contentPickerOutput.Id); + Assert.AreEqual(2, contentPickerOutput.Properties.Count); + Assert.AreEqual(987, contentPickerOutput.Properties["number"]); + var nestedContentPickerOutput = contentPickerOutput.Properties["nestedContentPicker"] as IApiContent; + Assert.IsNotNull(nestedContentPickerOutput); + Assert.AreEqual(nestedContentPickerValue.Key, nestedContentPickerOutput.Id); + Assert.AreEqual(2, nestedContentPickerOutput.Properties.Count); + Assert.AreEqual(111, nestedContentPickerOutput.Properties["numberOne"]); + Assert.AreEqual(222, nestedContentPickerOutput.Properties["numberTwo"]); + } + + [Test] + public void OutputExpansionStrategy_CanExpandContentPickerBeyondTwoLevels() + { + var accessor = CreateOutputExpansionStrategyAccessor($"properties[level1Picker[properties[level2Picker[properties[level3Picker[properties[level4Picker]]]]]]]"); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var level5PickedContent = CreateSimplePickedContent(1234, 5678); + var level4PickedContent = CreateMultiLevelPickedContent(444, level5PickedContent, "level4Picker", apiContentBuilder); + var level3PickedContent = CreateMultiLevelPickedContent(333, level4PickedContent, "level3Picker", apiContentBuilder); + var level2PickedContent = CreateMultiLevelPickedContent(222, level3PickedContent, "level2Picker", apiContentBuilder); + var contentPickerContentProperty = CreateContentPickerProperty(content.Object, level2PickedContent.Key, "level1Picker", apiContentBuilder); + + SetupContentMock(content, contentPickerContentProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(1, result.Properties.Count); + + var level1PickerOutput = result.Properties["level1Picker"] as ApiContent; + Assert.IsNotNull(level1PickerOutput); + Assert.AreEqual(level2PickedContent.Key, level1PickerOutput.Id); + Assert.AreEqual(2, level1PickerOutput.Properties.Count); + Assert.AreEqual(222, level1PickerOutput.Properties["number"]); + + var level2PickerOutput = level1PickerOutput.Properties["level2Picker"] as ApiContent; + Assert.IsNotNull(level2PickerOutput); + Assert.AreEqual(level3PickedContent.Key, level2PickerOutput.Id); + Assert.AreEqual(2, level2PickerOutput.Properties.Count); + Assert.AreEqual(333, level2PickerOutput.Properties["number"]); + + var level3PickerOutput = level2PickerOutput.Properties["level3Picker"] as ApiContent; + Assert.IsNotNull(level3PickerOutput); + Assert.AreEqual(level4PickedContent.Key, level3PickerOutput.Id); + Assert.AreEqual(2, level3PickerOutput.Properties.Count); + Assert.AreEqual(444, level3PickerOutput.Properties["number"]); + + var level4PickerOutput = level3PickerOutput.Properties["level4Picker"] as ApiContent; + Assert.IsNotNull(level4PickerOutput); + Assert.AreEqual(level5PickedContent.Key, level4PickerOutput.Id); + Assert.AreEqual(2, level4PickerOutput.Properties.Count); + Assert.AreEqual(1234, level4PickerOutput.Properties["numberOne"]); + Assert.AreEqual(5678, level4PickerOutput.Properties["numberTwo"]); + } + + [TestCase("numberOne")] + [TestCase("numberTwo")] + public void OutputExpansionStrategy_CanLimitDirectFields(string includedField) + { + var accessor = CreateOutputExpansionStrategyAccessor(fields: $"properties[{includedField}]"); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = CreateSimplePickedContent(123, 456); + + var result = apiContentBuilder.Build(content); + + Assert.AreEqual(1, result.Properties.Count); + Assert.IsTrue(result.Properties.ContainsKey(includedField)); + Assert.AreEqual(includedField is "numberOne" ? 123 : 456, result.Properties[includedField]); + } + + [TestCase(false)] + [TestCase(true)] + public void OutputExpansionStrategy_CanLimitFieldsOfExpandedContent(bool expand) + { + var accessor = CreateOutputExpansionStrategyAccessor(expand ? "properties[$all]" : null, "properties[contentPickerOne[properties[numberOne]],contentPickerTwo[properties[numberTwo]]]"); + var apiContentBuilder = new ApiContentBuilder(new ApiContentNameProvider(), ApiContentRouteBuilder(), accessor); + + var content = new Mock(); + + var contentPickerOneContent = CreateSimplePickedContent(12, 34); + var contentPickerOneProperty = CreateContentPickerProperty(content.Object, contentPickerOneContent.Key, "contentPickerOne", apiContentBuilder); + var contentPickerTwoContent = CreateSimplePickedContent(56, 78); + var contentPickerTwoProperty = CreateContentPickerProperty(content.Object, contentPickerTwoContent.Key, "contentPickerTwo", apiContentBuilder); + + SetupContentMock(content, contentPickerOneProperty, contentPickerTwoProperty); + + var result = apiContentBuilder.Build(content.Object); + + Assert.AreEqual(2, result.Properties.Count); + + var contentPickerOneOutput = result.Properties["contentPickerOne"] as ApiContent; + Assert.IsNotNull(contentPickerOneOutput); + Assert.AreEqual(contentPickerOneContent.Key, contentPickerOneOutput.Id); + // yeah we shouldn't test two things in one unit test, but given the risk of false positives when testing + // conditional field limiting, this is preferable. + if (expand) + { + Assert.AreEqual(1, contentPickerOneOutput.Properties.Count); + Assert.AreEqual(12, contentPickerOneOutput.Properties["numberOne"]); + } + else + { + Assert.IsEmpty(contentPickerOneOutput.Properties); + } + + var contentPickerTwoOutput = result.Properties["contentPickerTwo"] as ApiContent; + Assert.IsNotNull(contentPickerTwoOutput); + Assert.AreEqual(contentPickerTwoContent.Key, contentPickerTwoOutput.Id); + if (expand) + { + Assert.AreEqual(1, contentPickerTwoOutput.Properties.Count); + Assert.AreEqual(78, contentPickerTwoOutput.Properties["numberTwo"]); + } + else + { + Assert.IsEmpty(contentPickerTwoOutput.Properties); + } + } + + protected override IOutputExpansionStrategyAccessor CreateOutputExpansionStrategyAccessor(string? expand = null, string? fields = null) + { + var httpContextMock = new Mock(); + var httpRequestMock = new Mock(); + var httpContextAccessorMock = new Mock(); + + httpRequestMock + .SetupGet(r => r.Query) + .Returns(new QueryCollection(new Dictionary { { "expand", expand }, { "fields", fields } })); + + httpContextMock.SetupGet(c => c.Request).Returns(httpRequestMock.Object); + httpContextAccessorMock.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object); + IOutputExpansionStrategy outputExpansionStrategy = new RequestContextOutputExpansionStrategyV2( + httpContextAccessorMock.Object, + new ApiPropertyRenderer(new NoopPublishedValueFallback()), + Mock.Of>()); + var outputExpansionStrategyAccessorMock = new Mock(); + outputExpansionStrategyAccessorMock.Setup(s => s.TryGetValue(out outputExpansionStrategy)).Returns(true); + + return outputExpansionStrategyAccessorMock.Object; + } + + protected override string? FormatExpandSyntax(bool expandAll = false, string[]? expandPropertyAliases = null) + => expandAll ? "$all" : expandPropertyAliases?.Any() is true ? $"properties[{string.Join(",", expandPropertyAliases)}]" : null; +} From 80631e77c9b5dc89d7aeac5d37e4777a8a0cd3e8 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Tue, 14 Nov 2023 12:49:02 +0100 Subject: [PATCH 34/36] Post merge fixes with versions and updated nuget packages --- ...co.Cms.Persistence.EFCore.SqlServer.csproj | 1 - ...braco.Cms.Persistence.EFCore.Sqlite.csproj | 2 +- .../Umbraco.Cms.Persistence.EFCore.csproj | 3 --- .../Umbraco.Cms.Persistence.Sqlite.csproj | 1 - src/Umbraco.Core/Umbraco.Core.csproj | 12 --------- .../Umbraco.Infrastructure.csproj | 25 ++++--------------- .../Umbraco.PublishedCache.NuCache.csproj | 1 - .../Umbraco.Web.BackOffice.csproj | 1 - .../Umbraco.Web.Common.csproj | 2 -- src/Umbraco.Web.UI/Umbraco.Web.UI.csproj | 10 +------- .../Umbraco.Tests.Integration.csproj | 2 -- .../Umbraco.Tests.UnitTests.csproj | 1 - 12 files changed, 7 insertions(+), 54 deletions(-) diff --git a/src/Umbraco.Cms.Persistence.EFCore.SqlServer/Umbraco.Cms.Persistence.EFCore.SqlServer.csproj b/src/Umbraco.Cms.Persistence.EFCore.SqlServer/Umbraco.Cms.Persistence.EFCore.SqlServer.csproj index 43b5c89848..04e711f8d9 100644 --- a/src/Umbraco.Cms.Persistence.EFCore.SqlServer/Umbraco.Cms.Persistence.EFCore.SqlServer.csproj +++ b/src/Umbraco.Cms.Persistence.EFCore.SqlServer/Umbraco.Cms.Persistence.EFCore.SqlServer.csproj @@ -6,7 +6,6 @@ - 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 d5f513b5cd..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,7 +6,7 @@ - + diff --git a/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj b/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj index 451ee7a7b7..3282f2622d 100644 --- a/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj +++ b/src/Umbraco.Cms.Persistence.EFCore/Umbraco.Cms.Persistence.EFCore.csproj @@ -9,9 +9,6 @@ - - - diff --git a/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj b/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj index f82fd15ee0..645e42d666 100644 --- a/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj +++ b/src/Umbraco.Cms.Persistence.Sqlite/Umbraco.Cms.Persistence.Sqlite.csproj @@ -6,7 +6,6 @@ - diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index bbabfc74e9..83491bbe6c 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -17,18 +17,6 @@ - - - - - - - - - - - - diff --git a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj index 9b75b889be..58c02da503 100644 --- a/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj +++ b/src/Umbraco.Infrastructure/Umbraco.Infrastructure.csproj @@ -13,7 +13,7 @@ - + @@ -25,30 +25,15 @@ - + - - - - - - - - - - - - - - - - + - - + + diff --git a/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj b/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj index 4ceef9469e..a61eba6c79 100644 --- a/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj +++ b/src/Umbraco.PublishedCache.NuCache/Umbraco.PublishedCache.NuCache.csproj @@ -7,7 +7,6 @@ - diff --git a/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj b/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj index 91f55470ab..a64d0d2408 100644 --- a/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj +++ b/src/Umbraco.Web.BackOffice/Umbraco.Web.BackOffice.csproj @@ -13,7 +13,6 @@ - diff --git a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj index b4f2571df3..dc0d0dc6fa 100644 --- a/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj +++ b/src/Umbraco.Web.Common/Umbraco.Web.Common.csproj @@ -12,8 +12,6 @@ - - diff --git a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj index 1a1f224046..347238afda 100644 --- a/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj +++ b/src/Umbraco.Web.UI/Umbraco.Web.UI.csproj @@ -17,15 +17,7 @@ - - - - - - - all - - + diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj index c917f0d120..3d79aa6295 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj +++ b/tests/Umbraco.Tests.Integration/Umbraco.Tests.Integration.csproj @@ -12,8 +12,6 @@ - - diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj index fa49f32f16..8432428bab 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Tests.UnitTests.csproj @@ -5,7 +5,6 @@ - From 46d5db51e89017d0e1a29545abbe6612f6c33242 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Tue, 14 Nov 2023 13:16:51 +0100 Subject: [PATCH 35/36] Post merge fix --- .../PropertyEditors/RichTextPropertyEditor.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs index 053e98d9cb..c8716b4a23 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyEditor.cs @@ -307,7 +307,7 @@ public class RichTextPropertyEditor : DataEditor internal class RichTextPropertyIndexValueFactory : 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) { var val = property.GetValue(culture, segment, published); @@ -328,6 +328,6 @@ public class RichTextPropertyEditor : DataEditor [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) - => GetIndexValues(property, culture, segment, published, Enumerable.Empty()); + => GetIndexValues(property, culture, segment, published, Enumerable.Empty(), StaticServiceProvider.Instance.GetRequiredService().GetAll().ToDictionary(x=>x.Key)); } } From 039543ab433905f442e979ad5d20d12ee5e787d5 Mon Sep 17 00:00:00 2001 From: Bjarke Berg Date: Tue, 14 Nov 2023 13:19:55 +0100 Subject: [PATCH 36/36] Post merge --- .../RichTextPropertyIndexValueFactory.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs b/src/Umbraco.Infrastructure/PropertyEditors/RichTextPropertyIndexValueFactory.cs index a16bdd9fec..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;