From 1496282b6e03e87bf2fb47f994496af455136388 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Mon, 26 Jan 2026 16:16:18 +0100 Subject: [PATCH 01/39] Document patch, variant name only --- .../Document/PatchDocumentController.cs | 54 +++++++++ .../Document/PatchDocumentControllerBase.cs | 35 ++++++ .../DocumentEditingPresentationFactory.cs | 26 +++++ .../IDocumentEditingPresentationFactory.cs | 2 + .../Document/DocumentValuePatchModel.cs | 21 ++++ .../Document/DocumentVariantPatchModel.cs | 19 ++++ .../Document/PatchDocumentRequestModel.cs | 22 ++++ .../ContentEditing/ContentPatchModel.cs | 29 +++++ .../ContentEditing/ContentPatchResult.cs | 24 ++++ .../ContentEditing/PropertyPatchModel.cs | 23 ++++ .../ContentEditing/VariantPatchModel.cs | 21 ++++ .../Services/ContentEditingService.cs | 29 +++++ .../Services/IContentEditingService.cs | 6 + .../Document/PatchDocumentControllerTests.cs | 105 ++++++++++++++++++ 14 files changed, 416 insertions(+) create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs create mode 100644 src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentValuePatchModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantPatchModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs create mode 100644 src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs create mode 100644 src/Umbraco.Core/Models/ContentEditing/ContentPatchResult.cs create mode 100644 src/Umbraco.Core/Models/ContentEditing/PropertyPatchModel.cs create mode 100644 src/Umbraco.Core/Models/ContentEditing/VariantPatchModel.cs create mode 100644 tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs new file mode 100644 index 000000000000..c768d7047912 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs @@ -0,0 +1,54 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +[ApiVersion("1.0")] +public class PatchDocumentController : PatchDocumentControllerBase +{ + private readonly IContentEditingService _contentEditingService; + private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; + private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + + public PatchDocumentController( + IAuthorizationService authorizationService, + IContentEditingService contentEditingService, + IDocumentEditingPresentationFactory documentEditingPresentationFactory, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : base(authorizationService) + { + _contentEditingService = contentEditingService; + _documentEditingPresentationFactory = documentEditingPresentationFactory; + _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + } + + [HttpPatch("{id:guid}")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [Consumes("application/merge-patch+json")] + public async Task Patch( + CancellationToken cancellationToken, + Guid id, + PatchDocumentRequestModel requestModel) + => await HandleRequest(id, requestModel, async () => + { + ContentPatchModel model = _documentEditingPresentationFactory.MapPatchModel(requestModel); + Attempt result = + await _contentEditingService.PatchAsync(id, model, CurrentUserKey(_backOfficeSecurityAccessor)); + + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Status); + }); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs new file mode 100644 index 000000000000..e64723fbbdf5 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Security.Authorization; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +public abstract class PatchDocumentControllerBase : DocumentControllerBase +{ + private readonly IAuthorizationService _authorizationService; + + protected PatchDocumentControllerBase(IAuthorizationService authorizationService) + => _authorizationService = authorizationService; + + protected async Task HandleRequest(Guid id, PatchDocumentRequestModel requestModel, Func> authorizedHandler) + { + // We intentionally don't pass in cultures here. + // This is to support the client sending values for all cultures even if the user doesn't have access to the language. + // Values for unauthorized languages are later ignored in the ContentEditingService. + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ContentPermissionResource.WithKeys(ActionUpdate.ActionLetter, id), + AuthorizationPolicies.ContentPermissionByResource); + + if (authorizationResult.Succeeded is false) + { + return Forbidden(); + } + + return await authorizedHandler(); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs index 31b1a9a66f50..38b25e7a6a3f 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs @@ -19,6 +19,32 @@ public ContentCreateModel MapCreateModel(CreateDocumentRequestModel requestModel public ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel) => MapUpdateContentModel(requestModel); + public ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel) + { + return new ContentPatchModel + { + TemplateKey = requestModel.Template?.Id, + Variants = requestModel.Variants?.Select(v => new VariantPatchModel + { + Culture = v.Culture, + Segment = v.Segment, + Name = v.Name + }), + Properties = requestModel.Values?.Select(v => new PropertyPatchModel + { + Alias = v.Alias, + Culture = v.Culture, + Segment = v.Segment, + Value = v.Value + }), + AffectedCultures = requestModel.Variants? + .Where(v => v.Culture != null) + .Select(v => v.Culture!) + .Distinct() + .ToArray() ?? Array.Empty() + }; + } + public ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel) { ValidateContentUpdateModel model = MapUpdateContentModel(requestModel); diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs index 52978698d4b7..0a4c28f4a1f3 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs @@ -9,5 +9,7 @@ public interface IDocumentEditingPresentationFactory ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel); + ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel); + ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel); } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentValuePatchModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentValuePatchModel.cs new file mode 100644 index 000000000000..deeeb7979aa1 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentValuePatchModel.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; + +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +/// +/// Value patch model for document properties. +/// +public class DocumentValuePatchModel +{ + [Required] + public string Alias { get; set; } = string.Empty; + + public string? Culture { get; set; } + + public string? Segment { get; set; } + + /// + /// New value. Null explicitly clears the value. + /// + public object? Value { get; set; } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantPatchModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantPatchModel.cs new file mode 100644 index 000000000000..c3b8672a8039 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantPatchModel.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; + +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +/// +/// Variant patch model for documents. +/// +public class DocumentVariantPatchModel +{ + public string? Culture { get; set; } + + public string? Segment { get; set; } + + /// + /// New name for this variant. Required if variant is specified. + /// + [Required] + public string Name { get; set; } = string.Empty; +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs new file mode 100644 index 000000000000..a25f9c4ba37a --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs @@ -0,0 +1,22 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +/// +/// Request model for PATCH operations on documents. +/// +public class PatchDocumentRequestModel +{ + /// + /// Template to apply. Null preserves existing, explicit null reference clears. + /// + public ReferenceByIdModel? Template { get; set; } + + /// + /// Variants to update. Only variants present will be modified. + /// + public DocumentVariantPatchModel[]? Variants { get; set; } + + /// + /// Property values to update. Only values present will be modified. + /// + public DocumentValuePatchModel[]? Values { get; set; } +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs b/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs new file mode 100644 index 000000000000..fc9e57059695 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs @@ -0,0 +1,29 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +/// +/// Model for partial content updates (PATCH operations). +/// +public class ContentPatchModel +{ + /// + /// Template to apply. Null means preserve existing. + /// + public Guid? TemplateKey { get; set; } + + /// + /// Variants to patch. Only these variants will be modified. + /// Null means preserve all existing variants. + /// + public IEnumerable? Variants { get; set; } + + /// + /// Property values to patch. Only these properties will be modified. + /// Null means preserve all existing property values. + /// + public IEnumerable? Properties { get; set; } + + /// + /// Cultures explicitly affected by this patch. Used for authorization checks. + /// + public IEnumerable AffectedCultures { get; set; } = Array.Empty(); +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentPatchResult.cs b/src/Umbraco.Core/Models/ContentEditing/ContentPatchResult.cs new file mode 100644 index 000000000000..0356a44bd534 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ContentPatchResult.cs @@ -0,0 +1,24 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +/// +/// Result of a content patch operation. +/// +public class ContentPatchResult +{ + public required IContent Content { get; init; } + + /// + /// Cultures that were modified by this patch. + /// + public IEnumerable AffectedCultures { get; init; } = Array.Empty(); + + /// + /// Property aliases that were modified by this patch. + /// + public IEnumerable AffectedProperties { get; init; } = Array.Empty(); + + /// + /// Validation result for properties. + /// + public ContentValidationResult ValidationResult { get; init; } = new(); +} diff --git a/src/Umbraco.Core/Models/ContentEditing/PropertyPatchModel.cs b/src/Umbraco.Core/Models/ContentEditing/PropertyPatchModel.cs new file mode 100644 index 000000000000..4257c0cca773 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/PropertyPatchModel.cs @@ -0,0 +1,23 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +/// +/// Model for patching a specific property value. +/// +public class PropertyPatchModel +{ + public required string Alias { get; set; } + + public string? Culture { get; set; } + + public string? Segment { get; set; } + + public object? Value { get; set; } + + /// + /// Gets the composite key for this property value. + /// + /// + /// TODO: Consider refactoring to a base interface or abstract class with IKeyed pattern for consistency. + /// + public (string alias, string? culture, string? segment) Key => (Alias, Culture, Segment); +} diff --git a/src/Umbraco.Core/Models/ContentEditing/VariantPatchModel.cs b/src/Umbraco.Core/Models/ContentEditing/VariantPatchModel.cs new file mode 100644 index 000000000000..6ecaa6208619 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/VariantPatchModel.cs @@ -0,0 +1,21 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +/// +/// Model for patching a specific variant. +/// +public class VariantPatchModel +{ + public string? Culture { get; set; } + + public string? Segment { get; set; } + + public required string Name { get; set; } + + /// + /// Gets the composite key for this variant. + /// + /// + /// TODO: Consider refactoring to a base interface or abstract class with IKeyed pattern for consistency. + /// + public (string? culture, string? segment) Key => (Culture, Segment); +} diff --git a/src/Umbraco.Core/Services/ContentEditingService.cs b/src/Umbraco.Core/Services/ContentEditingService.cs index 9978eaff4dc8..55252495c5b9 100644 --- a/src/Umbraco.Core/Services/ContentEditingService.cs +++ b/src/Umbraco.Core/Services/ContentEditingService.cs @@ -274,6 +274,35 @@ public async Task> U : Attempt.FailWithStatus(saveStatus, new ContentUpdateResult { Content = content }); } + public async Task> PatchAsync(Guid key, ContentPatchModel patchModel, Guid userKey) + { + IContent? content = ContentService.GetById(key); + if (content is null) + { + return Attempt.FailWithStatus(ContentEditingOperationStatus.NotFound, new ContentPatchResult { Content = null! }); + } + + // Apply variant changes (name only for now - Phase 1 minimal implementation) + if (patchModel.Variants is not null) + { + foreach (var variantPatch in patchModel.Variants) + { + content.SetCultureName(variantPatch.Name, variantPatch.Culture); + } + } + + // Save the content + ContentEditingOperationStatus saveStatus = await Save(content, userKey); + return saveStatus == ContentEditingOperationStatus.Success + ? Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, new ContentPatchResult + { + Content = content, + AffectedCultures = patchModel.AffectedCultures, + AffectedProperties = patchModel.Properties?.Select(p => p.Alias).Distinct() ?? Array.Empty() + }) + : Attempt.FailWithStatus(saveStatus, new ContentPatchResult { Content = content }); + } + public async Task> MoveToRecycleBinAsync(Guid key, Guid userKey) => await HandleMoveToRecycleBinAsync(key, userKey); diff --git a/src/Umbraco.Core/Services/IContentEditingService.cs b/src/Umbraco.Core/Services/IContentEditingService.cs index 0d1ef9ae42f5..d339e9c10871 100644 --- a/src/Umbraco.Core/Services/IContentEditingService.cs +++ b/src/Umbraco.Core/Services/IContentEditingService.cs @@ -16,6 +16,12 @@ public interface IContentEditingService Task> UpdateAsync(Guid key, ContentUpdateModel updateModel, Guid userKey); + /// + /// Partially updates a Content Item (PATCH operation). + /// Only the specified properties and variants will be modified. + /// + Task> PatchAsync(Guid key, ContentPatchModel patchModel, Guid userKey); + Task> MoveToRecycleBinAsync(Guid key, Guid userKey); /// diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs new file mode 100644 index 000000000000..8687639f2683 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs @@ -0,0 +1,105 @@ +using System.Linq.Expressions; +using System.Net; +using System.Net.Http.Json; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Controllers.Document; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.ManagementApi.Document; + +public class PatchDocumentControllerTests : ManagementApiUserGroupTestBase +{ + private IContentEditingService ContentEditingService => GetRequiredService(); + + private ITemplateService TemplateService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private Guid _templateKey; + private Guid _documentKey; + + [SetUp] + public async Task Setup() + { + // Template + var template = TemplateBuilder.CreateTextPageTemplate(Guid.NewGuid().ToString()); + var templateResponse = await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + _templateKey = templateResponse.Result.Key; + + // Content Type + var contentType = ContentTypeBuilder.CreateTextPageContentType( + defaultTemplateId: template.Id, + name: Guid.NewGuid().ToString(), + alias: Guid.NewGuid().ToString()); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Content + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + TemplateKey = _templateKey, + ParentKey = Constants.System.RootKey, + Variants = new List { new() { Name = "Original Name" } }, + }; + var response = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + if (response.Result.Content is null) + { + throw new ArgumentNullException(nameof(response.Result.Content), "Setup failed: No content returned from CreateAsync"); + } + + _documentKey = response.Result.Content.Key; + } + + protected override Expression> MethodSelector => + x => x.Patch(CancellationToken.None, _documentKey, null); + + protected override UserGroupAssertionModel AdminUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.OK, + }; + + protected override UserGroupAssertionModel EditorUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.OK + }; + + protected override UserGroupAssertionModel SensitiveDataUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Forbidden + }; + + protected override UserGroupAssertionModel TranslatorUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Forbidden + }; + + protected override UserGroupAssertionModel WriterUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.OK + }; + + protected override UserGroupAssertionModel UnauthorizedUserGroupAssertionModel => new() + { + ExpectedStatusCode = HttpStatusCode.Unauthorized + }; + + protected override async Task ClientRequest() + { + PatchDocumentRequestModel patchModel = new() + { + Variants = new DocumentVariantPatchModel[] + { + new() { Culture = null, Segment = null, Name = "Updated Name" }, + }, + }; + + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/merge-patch+json"); + return await Client.PatchAsync(Url, httpContent); + } +} From 90f55f3d10e20fe8cb9b6a2273731274366f394f Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Mon, 26 Jan 2026 23:00:17 +0100 Subject: [PATCH 02/39] Multi variant tests --- .../Services/ContentEditingService.cs | 13 +- .../Document/PatchDocumentControllerTests.cs | 199 ++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Core/Services/ContentEditingService.cs b/src/Umbraco.Core/Services/ContentEditingService.cs index 55252495c5b9..d89cfe2dc9bf 100644 --- a/src/Umbraco.Core/Services/ContentEditingService.cs +++ b/src/Umbraco.Core/Services/ContentEditingService.cs @@ -282,7 +282,18 @@ public async Task> Pa return Attempt.FailWithStatus(ContentEditingOperationStatus.NotFound, new ContentPatchResult { Content = null! }); } - // Apply variant changes (name only for now - Phase 1 minimal implementation) + // Validate cultures - check all requested cultures are valid + if (patchModel.AffectedCultures.Any()) + { + var availableCultures = (await _languageService.GetAllIsoCodesAsync()).ToArray(); + var invalidCultures = patchModel.AffectedCultures.Except(availableCultures).ToArray(); + if (invalidCultures.Any()) + { + return Attempt.FailWithStatus(ContentEditingOperationStatus.InvalidCulture, new ContentPatchResult { Content = content }); + } + } + + // Apply variant changes (name only for now - Phase 2: added culture support) if (patchModel.Variants is not null) { foreach (var variantPatch in patchModel.Variants) diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs index 8687639f2683..f9cb4760a616 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs @@ -5,12 +5,15 @@ using Umbraco.Cms.Api.Management.Controllers.Document; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; namespace Umbraco.Cms.Tests.Integration.ManagementApi.Document; +[NonParallelizable] public class PatchDocumentControllerTests : ManagementApiUserGroupTestBase { private IContentEditingService ContentEditingService => GetRequiredService(); @@ -19,6 +22,8 @@ public class PatchDocumentControllerTests : ManagementApiUserGroupTestBase GetRequiredService(); + private ILanguageService LanguageService => GetRequiredService(); + private Guid _templateKey; private Guid _documentKey; @@ -102,4 +107,198 @@ protected override async Task ClientRequest() httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/merge-patch+json"); return await Client.PatchAsync(Url, httpContent); } + + [Test] + public async Task PatchDocument_SingleCulture_UpdatesOnlyThatCulture() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Arrange - Create languages + var langEnUs = new LanguageBuilder() + .WithCultureInfo("en-US") + .Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + + var langDaDk = new LanguageBuilder() + .WithCultureInfo("da-DK") + .Build(); + await LanguageService.CreateAsync(langDaDk, Constants.Security.SuperUserKey); + + // Arrange - Create content type with culture variation + var contentType = ContentTypeBuilder.CreateSimpleContentType("cultureTest", "Culture Test"); + contentType.Variations = ContentVariation.Culture; + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create document with multiple cultures + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = new List + { + new() { Name = "English Name", Culture = "en-US" }, + new() { Name = "Danish Name", Culture = "da-DK" }, + }, + }; + var createResponse = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + if (createResponse.Result.Content is null) + { + throw new InvalidOperationException("Failed to create test content"); + } + + var documentKey = createResponse.Result.Content.Key; + + // Patch only en-US using authenticated admin client + var patchModel = new PatchDocumentRequestModel + { + Variants = new DocumentVariantPatchModel[] + { + new() { Culture = "en-US", Segment = null, Name = "Updated English Name" } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/merge-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify only en-US was updated + var content = await ContentEditingService.GetAsync(documentKey); + Assert.IsNotNull(content); + Assert.AreEqual("Updated English Name", content.GetCultureName("en-US")); + Assert.AreEqual("Danish Name", content.GetCultureName("da-DK")); // Unchanged + } + + [Test] + public async Task PatchDocument_MultipleCultures_UpdatesBoth() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Arrange - Create languages + var langEnUs = new LanguageBuilder() + .WithCultureInfo("en-US") + .Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + + var langDaDk = new LanguageBuilder() + .WithCultureInfo("da-DK") + .Build(); + await LanguageService.CreateAsync(langDaDk, Constants.Security.SuperUserKey); + + var langDeDe = new LanguageBuilder() + .WithCultureInfo("de-DE") + .Build(); + await LanguageService.CreateAsync(langDeDe, Constants.Security.SuperUserKey); + + // Arrange - Create content type with culture variation + var contentType = ContentTypeBuilder.CreateSimpleContentType("multiCultureTest", "Multi Culture Test"); + contentType.Variations = ContentVariation.Culture; + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create document with multiple cultures + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = new List + { + new() { Name = "English Name", Culture = "en-US" }, + new() { Name = "Danish Name", Culture = "da-DK" }, + new() { Name = "German Name", Culture = "de-DE" }, + }, + }; + var createResponse = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + if (createResponse.Result.Content is null) + { + throw new InvalidOperationException("Failed to create test content"); + } + + var documentKey = createResponse.Result.Content.Key; + + // Patch en-US and da-DK using authenticated admin client + var patchModel = new PatchDocumentRequestModel + { + Variants = new DocumentVariantPatchModel[] + { + new() { Culture = "en-US", Segment = null, Name = "Updated English" }, + new() { Culture = "da-DK", Segment = null, Name = "Updated Danish" } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/merge-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify both cultures updated + var content = await ContentEditingService.GetAsync(documentKey); + Assert.IsNotNull(content); + Assert.AreEqual("Updated English", content.GetCultureName("en-US")); + Assert.AreEqual("Updated Danish", content.GetCultureName("da-DK")); + Assert.AreEqual("German Name", content.GetCultureName("de-DE")); // Unchanged + } + + [Test] + public async Task PatchDocument_NonExistentCulture_ReturnsInvalidCulture() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Arrange - Create language (only en-US, not fr-FR) + var langEnUs = new LanguageBuilder() + .WithCultureInfo("en-US") + .Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + + // Arrange - Create content type with culture variation + var contentType = ContentTypeBuilder.CreateSimpleContentType("invalidCultureTest", "Invalid Culture Test"); + contentType.Variations = ContentVariation.Culture; + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create document with en-US + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = new List + { + new() { Name = "English Name", Culture = "en-US" }, + }, + }; + var createResponse = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + if (createResponse.Result.Content is null) + { + throw new InvalidOperationException("Failed to create test content"); + } + + var documentKey = createResponse.Result.Content.Key; + + // Try to patch non-existent culture using authenticated admin client + var patchModel = new PatchDocumentRequestModel + { + Variants = new DocumentVariantPatchModel[] + { + new() { Culture = "fr-FR", Segment = null, Name = "French Name" } // fr-FR not enabled + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/merge-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + + // Assert + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); + } } From 6e246f1ad1c87df62a5a806b69113970afdd48ba Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 27 Jan 2026 16:56:04 +0100 Subject: [PATCH 03/39] Change to json-patch instead of merge to target nested properties --- Directory.Packages.props | 1 + .../Document/PatchDocumentController.cs | 36 +- .../Document/PatchDocumentControllerBase.cs | 33 ++ .../DocumentBuilderExtensions.cs | 2 + .../DocumentEditingPresentationFactory.cs | 47 ++- .../ContentPatchingOperationStatus.cs | 38 ++ .../Patchers/DocumentPatcher.cs | 202 ++++++++++ .../Document/DocumentValuePatchModel.cs | 21 -- .../Document/DocumentVariantPatchModel.cs | 19 - .../Document/PatchDocumentRequestModel.cs | 21 +- .../Document/PatchOperationRequestModel.cs | 26 ++ .../ContentEditing/ContentPatchModel.cs | 21 +- .../ContentEditing/PatchOperationModel.cs | 43 +++ .../ContentEditing/PropertyPatchModel.cs | 23 -- .../ContentEditing/VariantPatchModel.cs | 21 -- .../JsonPath/JsonPathCultureExtractor.cs | 146 ++++++++ .../JsonPath/JsonPathEvaluator.cs | 89 +++++ .../Services/ContentEditingService.cs | 40 -- .../Services/IContentEditingService.cs | 6 - src/Umbraco.Core/Umbraco.Core.csproj | 1 + .../Document/PatchDocumentControllerTests.cs | 348 +++++++++++++++++- .../JsonPath/JsonPathCultureExtractorTests.cs | 266 +++++++++++++ .../JsonPath/JsonPathEvaluatorTests.cs | 256 +++++++++++++ 23 files changed, 1510 insertions(+), 196 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/OperationStatus/ContentPatchingOperationStatus.cs create mode 100644 src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs delete mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentValuePatchModel.cs delete mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantPatchModel.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchOperationRequestModel.cs create mode 100644 src/Umbraco.Core/Models/ContentEditing/PatchOperationModel.cs delete mode 100644 src/Umbraco.Core/Models/ContentEditing/PropertyPatchModel.cs delete mode 100644 src/Umbraco.Core/Models/ContentEditing/VariantPatchModel.cs create mode 100644 src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathCultureExtractor.cs create mode 100644 src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathEvaluator.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathCultureExtractorTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathEvaluatorTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index d4daea05ef6b..75f420c40d95 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -48,6 +48,7 @@ + diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs index c768d7047912..e7e20588b2bd 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs @@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.OperationStatus; +using Umbraco.Cms.Api.Management.Patchers; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models.ContentEditing; @@ -16,18 +18,21 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document; public class PatchDocumentController : PatchDocumentControllerBase { private readonly IContentEditingService _contentEditingService; - private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; + private readonly DocumentPatcher _documentPatcher; + private readonly IDocumentEditingPresentationFactory _presentationFactory; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; public PatchDocumentController( IAuthorizationService authorizationService, IContentEditingService contentEditingService, - IDocumentEditingPresentationFactory documentEditingPresentationFactory, + DocumentPatcher documentPatcher, + IDocumentEditingPresentationFactory presentationFactory, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) : base(authorizationService) { _contentEditingService = contentEditingService; - _documentEditingPresentationFactory = documentEditingPresentationFactory; + _documentPatcher = documentPatcher; + _presentationFactory = presentationFactory; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; } @@ -36,19 +41,32 @@ public PatchDocumentController( [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] - [Consumes("application/merge-patch+json")] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + [Consumes("application/json-patch+json")] public async Task Patch( CancellationToken cancellationToken, Guid id, PatchDocumentRequestModel requestModel) => await HandleRequest(id, requestModel, async () => { - ContentPatchModel model = _documentEditingPresentationFactory.MapPatchModel(requestModel); - Attempt result = - await _contentEditingService.PatchAsync(id, model, CurrentUserKey(_backOfficeSecurityAccessor)); + // Map request model to domain model + ContentPatchModel patchModel = _presentationFactory.MapPatchModel(requestModel); - return result.Success + // Apply PATCH operations to create an update model + Attempt patchResult = + await _documentPatcher.ApplyPatchAsync(id, patchModel, CurrentUserKey(_backOfficeSecurityAccessor)); + + if (!patchResult.Success) + { + return ContentPatchingOperationStatusResult(patchResult.Status); + } + + // Use the standard update method to save the patched content + Attempt updateResult = + await _contentEditingService.UpdateAsync(id, patchResult.Result!, CurrentUserKey(_backOfficeSecurityAccessor)); + + return updateResult.Success ? Ok() - : ContentEditingOperationStatusResult(result.Status); + : ContentEditingOperationStatusResult(updateResult.Status); }); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs index e64723fbbdf5..19b13ad3e318 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs @@ -1,5 +1,8 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Common.Builders; +using Umbraco.Cms.Api.Management.OperationStatus; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core.Actions; using Umbraco.Cms.Core.Security.Authorization; @@ -32,4 +35,34 @@ protected async Task HandleRequest(Guid id, PatchDocumentRequestM return await authorizedHandler(); } + + /// + /// Maps ContentPatchingOperationStatus to appropriate HTTP responses for PATCH operations. + /// + protected IActionResult ContentPatchingOperationStatusResult(ContentPatchingOperationStatus status) + => OperationStatusResult(status, problemDetailsBuilder => status switch + { + ContentPatchingOperationStatus.InvalidOperation => BadRequest(problemDetailsBuilder + .WithTitle("Invalid operation") + .WithDetail("One or more PATCH operations were invalid. Check operation structure, JSONPath syntax, and operation types.") + .Build()), + ContentPatchingOperationStatus.InvalidCulture => BadRequest(problemDetailsBuilder + .WithTitle("Invalid culture") + .WithDetail("One or more cultures specified in operation paths are not valid or not configured.") + .Build()), + ContentPatchingOperationStatus.NotFound => NotFound(problemDetailsBuilder + .WithTitle("The document could not be found") + .Build()), + ContentPatchingOperationStatus.ContentTypeNotFound => NotFound(problemDetailsBuilder + .WithTitle("The document's content type could not be found") + .Build()), + ContentPatchingOperationStatus.PropertyTypeNotFound => UnprocessableEntity(problemDetailsBuilder + .WithTitle("Property type not found") + .WithDetail("One or more specified properties do not exist on the content type.") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder + .WithTitle("Unknown error") + .WithDetail("An unexpected error occurred during the PATCH operation.") + .Build()) + }); } diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs index 864f450ad1b0..2e856fb03736 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.Mapping.Document; +using Umbraco.Cms.Api.Management.Patchers; using Umbraco.Cms.Api.Management.Services.PermissionFilter; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Mapping; @@ -21,6 +22,7 @@ internal static IUmbracoBuilder AddDocuments(this IUmbracoBuilder builder) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.WithCollectionBuilder() .Add() diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs index 38b25e7a6a3f..06453ebf8263 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs @@ -21,27 +21,36 @@ public ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel public ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel) { + var cultureExtractor = new Umbraco.Cms.Core.PropertyEditors.JsonPath.JsonPathCultureExtractor(); + var operations = requestModel.Operations.Select(op => new PatchOperationModel + { + Op = MapOperationType(op.Op), + Path = op.Path, + Value = op.Value + }).ToArray(); + + var affectedCultures = cultureExtractor.ExtractCulturesFromOperations(operations.Select(o => o.Path)).ToArray(); + var affectedSegments = operations + .SelectMany(o => cultureExtractor.ExtractSegments(o.Path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + return new ContentPatchModel { - TemplateKey = requestModel.Template?.Id, - Variants = requestModel.Variants?.Select(v => new VariantPatchModel - { - Culture = v.Culture, - Segment = v.Segment, - Name = v.Name - }), - Properties = requestModel.Values?.Select(v => new PropertyPatchModel - { - Alias = v.Alias, - Culture = v.Culture, - Segment = v.Segment, - Value = v.Value - }), - AffectedCultures = requestModel.Variants? - .Where(v => v.Culture != null) - .Select(v => v.Culture!) - .Distinct() - .ToArray() ?? Array.Empty() + Operations = operations, + AffectedCultures = affectedCultures, + AffectedSegments = affectedSegments + }; + } + + private static PatchOperationType MapOperationType(string op) + { + return op.ToLowerInvariant() switch + { + "replace" => PatchOperationType.Replace, + "add" => PatchOperationType.Add, + "remove" => PatchOperationType.Remove, + _ => throw new ArgumentException($"Unsupported operation type: {op}", nameof(op)) }; } diff --git a/src/Umbraco.Cms.Api.Management/OperationStatus/ContentPatchingOperationStatus.cs b/src/Umbraco.Cms.Api.Management/OperationStatus/ContentPatchingOperationStatus.cs new file mode 100644 index 000000000000..b37ab9b5dee8 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/OperationStatus/ContentPatchingOperationStatus.cs @@ -0,0 +1,38 @@ +namespace Umbraco.Cms.Api.Management.OperationStatus; + +/// +/// Operation status for PATCH operations at the API layer. +/// This is distinct from ContentEditingOperationStatus which is for service layer operations. +/// +public enum ContentPatchingOperationStatus +{ + /// + /// The operation was successful. + /// + Success, + + /// + /// One or more PATCH operations were invalid (invalid JSONPath syntax, unsupported operation type, missing required value). + /// + InvalidOperation, + + /// + /// One or more cultures specified in operation paths are not valid or not configured. + /// + InvalidCulture, + + /// + /// The target document could not be found. + /// + NotFound, + + /// + /// The document's content type could not be found. + /// + ContentTypeNotFound, + + /// + /// One or more property types specified in operations do not exist on the content type. + /// + PropertyTypeNotFound, +} diff --git a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs new file mode 100644 index 000000000000..fa736824edc4 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs @@ -0,0 +1,202 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using Umbraco.Cms.Api.Management.OperationStatus; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.PropertyEditors.JsonPath; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Patchers; + +/// +/// Applies JSON Patch operations with JSONPath to documents, converting them to update models. +/// +public class DocumentPatcher +{ + private readonly IContentEditingService _contentEditingService; + private readonly IContentTypeService _contentTypeService; + private readonly ILanguageService _languageService; + private readonly JsonPathEvaluator _jsonPathEvaluator; + private readonly JsonPathCultureExtractor _cultureExtractor; + + public DocumentPatcher( + IContentEditingService contentEditingService, + IContentTypeService contentTypeService, + ILanguageService languageService) + { + _contentEditingService = contentEditingService; + _contentTypeService = contentTypeService; + _languageService = languageService; + _jsonPathEvaluator = new JsonPathEvaluator(); + _cultureExtractor = new JsonPathCultureExtractor(); + } + + /// + /// Applies PATCH operations to a document and returns an update model. + /// Validates operations and returns appropriate error status if validation fails. + /// + /// The document key. + /// The patch model containing operations and affected cultures/segments. + /// The user performing the operation. + /// An attempt containing the update model or an error status. + public async Task> ApplyPatchAsync( + Guid documentKey, + ContentPatchModel patchModel, + Guid userKey) + { + // Validate operation structure + foreach (PatchOperationModel operation in patchModel.Operations) + { + if (!_jsonPathEvaluator.IsValidExpression(operation.Path)) + { + return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(ContentUpdateModel)!); + } + + // Validate that replace/add operations have a value + if ((operation.Op == PatchOperationType.Replace || operation.Op == PatchOperationType.Add) && + operation.Value is null) + { + return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(ContentUpdateModel)!); + } + } + + // Load the content + IContent? content = await _contentEditingService.GetAsync(documentKey); + if (content is null) + { + return Attempt.FailWithStatus(ContentPatchingOperationStatus.NotFound, default(ContentUpdateModel)!); + } + + // Validate cultures (cultures are already extracted in the patch model) + if (patchModel.AffectedCultures.Any()) + { + IEnumerable allLanguages = await _languageService.GetAllAsync(); + var availableCultures = allLanguages.Select(l => l.IsoCode).ToHashSet(StringComparer.OrdinalIgnoreCase); + var invalidCultures = patchModel.AffectedCultures.Where(c => !availableCultures.Contains(c)).ToArray(); + if (invalidCultures.Any()) + { + return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidCulture, default(ContentUpdateModel)!); + } + } + + // Get content type + IContentType? contentType = _contentTypeService.Get(content.ContentTypeId); + if (contentType is null) + { + return Attempt.FailWithStatus(ContentPatchingOperationStatus.ContentTypeNotFound, default(ContentUpdateModel)!); + } + + // Apply operations to build update model + var variants = new Dictionary<(string? Culture, string? Segment), VariantModel>(); + var properties = new List(); + Guid? templateKey = null; + + foreach (PatchOperationModel operation in patchModel.Operations) + { + // Parse the JSONPath to determine what to update + if (operation.Path.Contains("$.variants")) + { + // Variant operation (name update) + var culture = _cultureExtractor.ExtractCultures(operation.Path).FirstOrDefault(); + var segment = _cultureExtractor.ExtractSegments(operation.Path).FirstOrDefault(); + + if (operation.Op != PatchOperationType.Replace && operation.Op != PatchOperationType.Add) + { + continue; + } + + var key = (culture, segment); + if (!variants.ContainsKey(key)) + { + variants[key] = new VariantModel + { + Culture = culture, + Segment = segment, + Name = string.Empty + }; + } + + if (operation.Path.Contains(".name")) + { + variants[key].Name = operation.Value?.ToString() ?? string.Empty; + } + } + else if (operation.Path.Contains("$.values")) + { + // Property value operation + var culture = _cultureExtractor.ExtractCultures(operation.Path).FirstOrDefault(); + var segment = _cultureExtractor.ExtractSegments(operation.Path).FirstOrDefault(); + + // Extract property alias from the path (simplified parsing) + // Path format: $.values[?(@.alias == 'propertyAlias' && ...)].value + Match aliasMatch = Regex.Match( + operation.Path, + @"@\.alias\s*==\s*['""]([^'""]+)['""]"); + + if (!aliasMatch.Success) + { + return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(ContentUpdateModel)!); + } + + var propertyAlias = aliasMatch.Groups[1].Value; + + // Validate property exists on the content type + IPropertyType? propertyType = contentType.CompositionPropertyTypes.FirstOrDefault(pt => pt.Alias == propertyAlias); + if (propertyType is null) + { + return Attempt.FailWithStatus(ContentPatchingOperationStatus.PropertyTypeNotFound, default(ContentUpdateModel)!); + } + + if (operation.Op == PatchOperationType.Replace || operation.Op == PatchOperationType.Add) + { + properties.Add(new PropertyValueModel + { + Alias = propertyAlias, + Value = operation.Value, + Culture = culture, + Segment = segment, + }); + } + else if (operation.Op == PatchOperationType.Remove) + { + properties.Add(new PropertyValueModel + { + Alias = propertyAlias, + Value = null, + Culture = culture, + Segment = segment, + }); + } + } + else if (operation.Path.Contains("$.template")) + { + // Template operation + if (operation.Op == PatchOperationType.Replace || operation.Op == PatchOperationType.Add) + { + if (operation.Value is JsonElement jsonElement && jsonElement.TryGetProperty("id", out JsonElement idElement)) + { + templateKey = idElement.GetGuid(); + } + else if (operation.Value is Guid guid) + { + templateKey = guid; + } + } + else if (operation.Op == PatchOperationType.Remove) + { + templateKey = null; + } + } + } + + var updateModel = new ContentUpdateModel + { + Variants = variants.Values.ToArray(), + Properties = properties.ToArray(), + TemplateKey = templateKey, + }; + + return Attempt.SucceedWithStatus(ContentPatchingOperationStatus.Success, updateModel); + } +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentValuePatchModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentValuePatchModel.cs deleted file mode 100644 index deeeb7979aa1..000000000000 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentValuePatchModel.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Umbraco.Cms.Api.Management.ViewModels.Document; - -/// -/// Value patch model for document properties. -/// -public class DocumentValuePatchModel -{ - [Required] - public string Alias { get; set; } = string.Empty; - - public string? Culture { get; set; } - - public string? Segment { get; set; } - - /// - /// New value. Null explicitly clears the value. - /// - public object? Value { get; set; } -} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantPatchModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantPatchModel.cs deleted file mode 100644 index c3b8672a8039..000000000000 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentVariantPatchModel.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Umbraco.Cms.Api.Management.ViewModels.Document; - -/// -/// Variant patch model for documents. -/// -public class DocumentVariantPatchModel -{ - public string? Culture { get; set; } - - public string? Segment { get; set; } - - /// - /// New name for this variant. Required if variant is specified. - /// - [Required] - public string Name { get; set; } = string.Empty; -} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs index a25f9c4ba37a..88e9d2d6cb8f 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs @@ -1,22 +1,17 @@ +using System.ComponentModel.DataAnnotations; + namespace Umbraco.Cms.Api.Management.ViewModels.Document; /// -/// Request model for PATCH operations on documents. +/// Request model for operation-based PATCH on documents using JSON Patch with JSONPath. /// public class PatchDocumentRequestModel { /// - /// Template to apply. Null preserves existing, explicit null reference clears. - /// - public ReferenceByIdModel? Template { get; set; } - - /// - /// Variants to update. Only variants present will be modified. - /// - public DocumentVariantPatchModel[]? Variants { get; set; } - - /// - /// Property values to update. Only values present will be modified. + /// Collection of PATCH operations to apply to the document. + /// Operations are applied sequentially and atomically (all-or-nothing). /// - public DocumentValuePatchModel[]? Values { get; set; } + [Required] + [MinLength(1)] + public PatchOperationRequestModel[] Operations { get; set; } = Array.Empty(); } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchOperationRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchOperationRequestModel.cs new file mode 100644 index 000000000000..0a49fb06b713 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchOperationRequestModel.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; + +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +/// +/// Represents a single PATCH operation following JSON Patch (RFC 6902) semantics with JSONPath. +/// +public class PatchOperationRequestModel +{ + /// + /// The operation type: "replace", "add", or "remove". + /// + [Required] + public string Op { get; set; } = string.Empty; + + /// + /// JSONPath expression identifying the target location (e.g., "$.values[?(@.alias == 'title' && @.culture == 'en-US')].value"). + /// + [Required] + public string Path { get; set; } = string.Empty; + + /// + /// The value to set. Required for "replace" and "add" operations, omitted for "remove". + /// + public object? Value { get; set; } +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs b/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs index fc9e57059695..c3fc039edc5e 100644 --- a/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs +++ b/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs @@ -1,29 +1,28 @@ namespace Umbraco.Cms.Core.Models.ContentEditing; /// -/// Model for partial content updates (PATCH operations). +/// Model for operation-based partial content updates (PATCH with JSONPath). /// public class ContentPatchModel { /// - /// Template to apply. Null means preserve existing. + /// Collection of PATCH operations to apply. /// - public Guid? TemplateKey { get; set; } + public PatchOperationModel[] Operations { get; set; } = Array.Empty(); /// - /// Variants to patch. Only these variants will be modified. - /// Null means preserve all existing variants. + /// Cultures explicitly affected by this patch (extracted from operation paths). + /// Used for authorization checks. /// - public IEnumerable? Variants { get; set; } + public IEnumerable AffectedCultures { get; set; } = Array.Empty(); /// - /// Property values to patch. Only these properties will be modified. - /// Null means preserve all existing property values. + /// Segments explicitly affected by this patch (extracted from operation paths). /// - public IEnumerable? Properties { get; set; } + public IEnumerable AffectedSegments { get; set; } = Array.Empty(); /// - /// Cultures explicitly affected by this patch. Used for authorization checks. + /// Property aliases explicitly affected by this patch (extracted from operation paths). /// - public IEnumerable AffectedCultures { get; set; } = Array.Empty(); + public IEnumerable AffectedProperties { get; set; } = Array.Empty(); } diff --git a/src/Umbraco.Core/Models/ContentEditing/PatchOperationModel.cs b/src/Umbraco.Core/Models/ContentEditing/PatchOperationModel.cs new file mode 100644 index 000000000000..3544cf047051 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/PatchOperationModel.cs @@ -0,0 +1,43 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +/// +/// Represents a single PATCH operation in the domain layer. +/// +public class PatchOperationModel +{ + /// + /// Gets or sets the operation type. + /// + public PatchOperationType Op { get; set; } + + /// + /// Gets or sets the JSONPath expression identifying the target location. + /// + public string Path { get; set; } = string.Empty; + + /// + /// Gets or sets the value to set. Required for Replace and Add operations, null for Remove. + /// + public object? Value { get; set; } +} + +/// +/// Defines the supported PATCH operation types. +/// +public enum PatchOperationType +{ + /// + /// Replace an existing value at the target location. + /// + Replace, + + /// + /// Add a new value at the target location. + /// + Add, + + /// + /// Remove the value at the target location. + /// + Remove +} diff --git a/src/Umbraco.Core/Models/ContentEditing/PropertyPatchModel.cs b/src/Umbraco.Core/Models/ContentEditing/PropertyPatchModel.cs deleted file mode 100644 index 4257c0cca773..000000000000 --- a/src/Umbraco.Core/Models/ContentEditing/PropertyPatchModel.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace Umbraco.Cms.Core.Models.ContentEditing; - -/// -/// Model for patching a specific property value. -/// -public class PropertyPatchModel -{ - public required string Alias { get; set; } - - public string? Culture { get; set; } - - public string? Segment { get; set; } - - public object? Value { get; set; } - - /// - /// Gets the composite key for this property value. - /// - /// - /// TODO: Consider refactoring to a base interface or abstract class with IKeyed pattern for consistency. - /// - public (string alias, string? culture, string? segment) Key => (Alias, Culture, Segment); -} diff --git a/src/Umbraco.Core/Models/ContentEditing/VariantPatchModel.cs b/src/Umbraco.Core/Models/ContentEditing/VariantPatchModel.cs deleted file mode 100644 index 6ecaa6208619..000000000000 --- a/src/Umbraco.Core/Models/ContentEditing/VariantPatchModel.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace Umbraco.Cms.Core.Models.ContentEditing; - -/// -/// Model for patching a specific variant. -/// -public class VariantPatchModel -{ - public string? Culture { get; set; } - - public string? Segment { get; set; } - - public required string Name { get; set; } - - /// - /// Gets the composite key for this variant. - /// - /// - /// TODO: Consider refactoring to a base interface or abstract class with IKeyed pattern for consistency. - /// - public (string? culture, string? segment) Key => (Culture, Segment); -} diff --git a/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathCultureExtractor.cs b/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathCultureExtractor.cs new file mode 100644 index 000000000000..ec5ff730a316 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathCultureExtractor.cs @@ -0,0 +1,146 @@ +using System.Text.RegularExpressions; + +namespace Umbraco.Cms.Core.PropertyEditors.JsonPath; + +/// +/// Extracts culture and segment information from JSONPath expressions for authorization purposes. +/// Parses filter expressions like "@.culture == 'en-US'" to extract the culture value. +/// +public class JsonPathCultureExtractor +{ + // Regex patterns for extracting culture and segment from JSONPath filter expressions + // Matches: @.culture == 'value' or @.culture == "value" + private static readonly Regex CulturePattern = new(@"@\.culture\s*==\s*['""]([^'""]+)['""]", RegexOptions.Compiled); + + // Matches: @.segment == 'value' or @.segment == "value" + private static readonly Regex SegmentPattern = new(@"@\.segment\s*==\s*['""]([^'""]+)['""]", RegexOptions.Compiled); + + // Matches: @.culture == null + private static readonly Regex CultureNullPattern = new(@"@\.culture\s*==\s*null\b", RegexOptions.Compiled); + + // Matches: @.segment == null + private static readonly Regex SegmentNullPattern = new(@"@\.segment\s*==\s*null\b", RegexOptions.Compiled); + + /// + /// Extracts all unique cultures referenced in a JSONPath expression. + /// + /// The JSONPath expression to parse. + /// A set of culture codes (e.g., "en-US", "da-DK"), or empty set if no cultures found. + public ISet ExtractCultures(string pathExpression) + { + if (string.IsNullOrWhiteSpace(pathExpression)) + { + return new HashSet(); + } + + var cultures = new HashSet(StringComparer.OrdinalIgnoreCase); + + var matches = CulturePattern.Matches(pathExpression); + foreach (Match match in matches) + { + if (match.Success && match.Groups.Count > 1) + { + cultures.Add(match.Groups[1].Value); + } + } + + return cultures; + } + + /// + /// Extracts all unique segments referenced in a JSONPath expression. + /// + /// The JSONPath expression to parse. + /// A set of segment names, or empty set if no segments found. + public ISet ExtractSegments(string pathExpression) + { + if (string.IsNullOrWhiteSpace(pathExpression)) + { + return new HashSet(); + } + + var segments = new HashSet(StringComparer.OrdinalIgnoreCase); + + var matches = SegmentPattern.Matches(pathExpression); + foreach (Match match in matches) + { + if (match.Success && match.Groups.Count > 1) + { + segments.Add(match.Groups[1].Value); + } + } + + return segments; + } + + /// + /// Checks if a JSONPath expression explicitly filters for null culture (invariant). + /// + /// The JSONPath expression to parse. + /// True if the expression contains "@.culture == null", false otherwise. + public bool ContainsInvariantCultureFilter(string pathExpression) + { + if (string.IsNullOrWhiteSpace(pathExpression)) + { + return false; + } + + return CultureNullPattern.IsMatch(pathExpression); + } + + /// + /// Checks if a JSONPath expression explicitly filters for null segment. + /// + /// The JSONPath expression to parse. + /// True if the expression contains "@.segment == null", false otherwise. + public bool ContainsNullSegmentFilter(string pathExpression) + { + if (string.IsNullOrWhiteSpace(pathExpression)) + { + return false; + } + + return SegmentNullPattern.IsMatch(pathExpression); + } + + /// + /// Extracts all cultures from a collection of JSONPath expressions. + /// + /// Collection of JSONPath expressions. + /// A set of all unique cultures across all expressions. + public ISet ExtractCulturesFromOperations(IEnumerable pathExpressions) + { + if (pathExpressions == null) + { + return new HashSet(); + } + + var allCultures = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var path in pathExpressions) + { + var cultures = ExtractCultures(path); + foreach (var culture in cultures) + { + allCultures.Add(culture); + } + } + + return allCultures; + } + + /// + /// Checks if any of the path expressions target invariant content (null culture). + /// + /// Collection of JSONPath expressions. + /// True if any expression contains invariant culture filter. + public bool AnyOperationTargetsInvariantCulture(IEnumerable pathExpressions) + { + if (pathExpressions == null) + { + return false; + } + + return pathExpressions.Any(ContainsInvariantCultureFilter); + } +} diff --git a/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathEvaluator.cs b/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathEvaluator.cs new file mode 100644 index 000000000000..232c4e7c637f --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathEvaluator.cs @@ -0,0 +1,89 @@ +using System.Text.Json; +using JsonCons.JsonPath; + +namespace Umbraco.Cms.Core.PropertyEditors.JsonPath; + +/// +/// Evaluates JSONPath expressions against JSON documents. +/// Wraps JsonCons.JsonPath library to provide RFC 9535 compliant JSONPath evaluation. +/// +public class JsonPathEvaluator +{ + /// + /// Evaluates a JSONPath expression against a JSON document and returns matching nodes. + /// + /// The JSON document to query. + /// The JSONPath expression (e.g., "$.values[?(@.alias == 'title')]"). + /// A list of matching JSON elements, or an empty list if no matches found. + /// Thrown when the path expression is invalid. + public IReadOnlyList Select(JsonDocument jsonDocument, string pathExpression) + { + if (jsonDocument == null) + { + throw new ArgumentNullException(nameof(jsonDocument)); + } + + if (string.IsNullOrWhiteSpace(pathExpression)) + { + throw new ArgumentException("Path expression cannot be null or empty.", nameof(pathExpression)); + } + + try + { + var selector = JsonSelector.Parse(pathExpression); + var results = selector.Select(jsonDocument.RootElement); + return results.ToList(); + } + catch (JsonException ex) + { + throw new ArgumentException($"Invalid JSONPath expression: {pathExpression}", nameof(pathExpression), ex); + } + } + + /// + /// Evaluates a JSONPath expression and returns the first matching node, or null if no match found. + /// + /// The JSON document to query. + /// The JSONPath expression. + /// The first matching JSON element, or null if no matches found. + public JsonElement? SelectSingle(JsonDocument jsonDocument, string pathExpression) + { + var results = Select(jsonDocument, pathExpression); + return results.Count > 0 ? results[0] : null; + } + + /// + /// Checks if a JSONPath expression matches any nodes in the document. + /// + /// The JSON document to query. + /// The JSONPath expression. + /// True if at least one node matches, false otherwise. + public bool Exists(JsonDocument jsonDocument, string pathExpression) + { + var results = Select(jsonDocument, pathExpression); + return results.Count > 0; + } + + /// + /// Validates that a JSONPath expression is syntactically correct. + /// + /// The JSONPath expression to validate. + /// True if the expression is valid, false otherwise. + public bool IsValidExpression(string pathExpression) + { + if (string.IsNullOrWhiteSpace(pathExpression)) + { + return false; + } + + try + { + JsonSelector.Parse(pathExpression); + return true; + } + catch + { + return false; + } + } +} diff --git a/src/Umbraco.Core/Services/ContentEditingService.cs b/src/Umbraco.Core/Services/ContentEditingService.cs index d89cfe2dc9bf..9978eaff4dc8 100644 --- a/src/Umbraco.Core/Services/ContentEditingService.cs +++ b/src/Umbraco.Core/Services/ContentEditingService.cs @@ -274,46 +274,6 @@ public async Task> U : Attempt.FailWithStatus(saveStatus, new ContentUpdateResult { Content = content }); } - public async Task> PatchAsync(Guid key, ContentPatchModel patchModel, Guid userKey) - { - IContent? content = ContentService.GetById(key); - if (content is null) - { - return Attempt.FailWithStatus(ContentEditingOperationStatus.NotFound, new ContentPatchResult { Content = null! }); - } - - // Validate cultures - check all requested cultures are valid - if (patchModel.AffectedCultures.Any()) - { - var availableCultures = (await _languageService.GetAllIsoCodesAsync()).ToArray(); - var invalidCultures = patchModel.AffectedCultures.Except(availableCultures).ToArray(); - if (invalidCultures.Any()) - { - return Attempt.FailWithStatus(ContentEditingOperationStatus.InvalidCulture, new ContentPatchResult { Content = content }); - } - } - - // Apply variant changes (name only for now - Phase 2: added culture support) - if (patchModel.Variants is not null) - { - foreach (var variantPatch in patchModel.Variants) - { - content.SetCultureName(variantPatch.Name, variantPatch.Culture); - } - } - - // Save the content - ContentEditingOperationStatus saveStatus = await Save(content, userKey); - return saveStatus == ContentEditingOperationStatus.Success - ? Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, new ContentPatchResult - { - Content = content, - AffectedCultures = patchModel.AffectedCultures, - AffectedProperties = patchModel.Properties?.Select(p => p.Alias).Distinct() ?? Array.Empty() - }) - : Attempt.FailWithStatus(saveStatus, new ContentPatchResult { Content = content }); - } - public async Task> MoveToRecycleBinAsync(Guid key, Guid userKey) => await HandleMoveToRecycleBinAsync(key, userKey); diff --git a/src/Umbraco.Core/Services/IContentEditingService.cs b/src/Umbraco.Core/Services/IContentEditingService.cs index d339e9c10871..0d1ef9ae42f5 100644 --- a/src/Umbraco.Core/Services/IContentEditingService.cs +++ b/src/Umbraco.Core/Services/IContentEditingService.cs @@ -16,12 +16,6 @@ public interface IContentEditingService Task> UpdateAsync(Guid key, ContentUpdateModel updateModel, Guid userKey); - /// - /// Partially updates a Content Item (PATCH operation). - /// Only the specified properties and variants will be modified. - /// - Task> PatchAsync(Guid key, ContentPatchModel patchModel, Guid userKey); - Task> MoveToRecycleBinAsync(Guid key, Guid userKey); /// diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 12fa70846f37..9eaf69097975 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -35,6 +35,7 @@ + diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs index f9cb4760a616..266f12dd9f85 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs @@ -8,8 +8,10 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Builders.Extensions; +using Umbraco.Cms.Tests.Common.Testing; namespace Umbraco.Cms.Tests.Integration.ManagementApi.Document; @@ -24,6 +26,10 @@ public class PatchDocumentControllerTests : ManagementApiUserGroupTestBase GetRequiredService(); + private IShortStringHelper ShortStringHelper => GetRequiredService(); + + private IContentService ContentService => GetRequiredService(); + private Guid _templateKey; private Guid _documentKey; @@ -97,14 +103,19 @@ protected override async Task ClientRequest() { PatchDocumentRequestModel patchModel = new() { - Variants = new DocumentVariantPatchModel[] + Operations = new[] { - new() { Culture = null, Segment = null, Name = "Updated Name" }, - }, + new PatchOperationRequestModel + { + Op = "replace", + Path = "$.variants[?(@.culture == null && @.segment == null)].name", + Value = "Updated Name" + } + } }; var httpContent = JsonContent.Create(patchModel); - httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/merge-patch+json"); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); return await Client.PatchAsync(Url, httpContent); } @@ -153,15 +164,20 @@ public async Task PatchDocument_SingleCulture_UpdatesOnlyThatCulture() // Patch only en-US using authenticated admin client var patchModel = new PatchDocumentRequestModel { - Variants = new DocumentVariantPatchModel[] + Operations = new[] { - new() { Culture = "en-US", Segment = null, Name = "Updated English Name" } + new PatchOperationRequestModel + { + Op = "replace", + Path = "$.variants[?(@.culture == 'en-US' && @.segment == null)].name", + Value = "Updated English Name" + } } }; // Act var httpContent = JsonContent.Create(patchModel); - httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/merge-patch+json"); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); // Assert @@ -225,16 +241,26 @@ public async Task PatchDocument_MultipleCultures_UpdatesBoth() // Patch en-US and da-DK using authenticated admin client var patchModel = new PatchDocumentRequestModel { - Variants = new DocumentVariantPatchModel[] + Operations = new[] { - new() { Culture = "en-US", Segment = null, Name = "Updated English" }, - new() { Culture = "da-DK", Segment = null, Name = "Updated Danish" } + new PatchOperationRequestModel + { + Op = "replace", + Path = "$.variants[?(@.culture == 'en-US' && @.segment == null)].name", + Value = "Updated English" + }, + new PatchOperationRequestModel + { + Op = "replace", + Path = "$.variants[?(@.culture == 'da-DK' && @.segment == null)].name", + Value = "Updated Danish" + } } }; // Act var httpContent = JsonContent.Create(patchModel); - httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/merge-patch+json"); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); // Assert @@ -287,18 +313,312 @@ public async Task PatchDocument_NonExistentCulture_ReturnsInvalidCulture() // Try to patch non-existent culture using authenticated admin client var patchModel = new PatchDocumentRequestModel { - Variants = new DocumentVariantPatchModel[] + Operations = new[] { - new() { Culture = "fr-FR", Segment = null, Name = "French Name" } // fr-FR not enabled + new PatchOperationRequestModel + { + Op = "replace", + Path = "$.variants[?(@.culture == 'fr-FR' && @.segment == null)].name", + Value = "French Name" // fr-FR not enabled + } } }; // Act var httpContent = JsonContent.Create(patchModel); - httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/merge-patch+json"); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); // Assert Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); } + + [Test] + public async Task PatchDocument_PropertyValue_UpdatesProperty() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Arrange - Create language + var langEnUs = new LanguageBuilder() + .WithCultureInfo("en-US") + .Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + + // Arrange - Create content type with properties and culture variation + var contentType = new ContentTypeBuilder() + .WithAlias("propertyTest") + .WithName("Property Test") + .WithContentVariation(ContentVariation.Culture) + .AddPropertyType() + .WithAlias("title") + .WithName("Title") + .WithDataTypeId(Constants.DataTypes.Textbox) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox) + .WithValueStorageType(ValueStorageType.Nvarchar) + .WithVariations(ContentVariation.Culture) + .Done() + .WithAllowAsRoot(true) + .Build(); + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create document with en-US variant using ContentBuilder and ContentService + var content = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", "English Name") + .Build(); + + // Set property value directly + content.SetValue("title", "Original Title", culture: "en-US"); + ContentService.Save(content); + + var documentKey = content.Key; + + // Patch title property for en-US + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "$.values[?(@.alias == 'title' && @.culture == 'en-US' && @.segment == null)].value", + Value = "Updated Title" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify property was updated + var updatedContent = await ContentEditingService.GetAsync(documentKey); + Assert.IsNotNull(updatedContent); + Assert.AreEqual("Updated Title", updatedContent.GetValue("title", "en-US")); + } + + [Test] + public async Task PatchDocument_MultipleProperties_UpdatesAll() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Arrange - Create language + var langEnUs = new LanguageBuilder() + .WithCultureInfo("en-US") + .Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + + // Arrange - Create content type with multiple properties and culture variation + var contentType = new ContentTypeBuilder() + .WithAlias("multiPropertyTest") + .WithName("Multi Property Test") + .WithContentVariation(ContentVariation.Culture) + .AddPropertyType() + .WithAlias("title") + .WithName("Title") + .WithDataTypeId(Constants.DataTypes.Textbox) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox) + .WithValueStorageType(ValueStorageType.Nvarchar) + .WithVariations(ContentVariation.Culture) + .Done() + .AddPropertyType() + .WithAlias("description") + .WithName("Description") + .WithDataTypeId(Constants.DataTypes.Textbox) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox) + .WithValueStorageType(ValueStorageType.Nvarchar) + .WithVariations(ContentVariation.Culture) + .Done() + .WithAllowAsRoot(true) + .Build(); + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create document with en-US variant using ContentBuilder and ContentService + var content = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", "English Name") + .Build(); + + // Set property values directly + content.SetValue("title", "Original Title", culture: "en-US"); + content.SetValue("description", "Original Description", culture: "en-US"); + ContentService.Save(content); + + var documentKey = content.Key; + + // Patch both properties for en-US + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "$.values[?(@.alias == 'title' && @.culture == 'en-US' && @.segment == null)].value", + Value = "Updated Title" + }, + new PatchOperationRequestModel + { + Op = "replace", + Path = "$.values[?(@.alias == 'description' && @.culture == 'en-US' && @.segment == null)].value", + Value = "Updated Description" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify both properties were updated + var updatedContent = await ContentEditingService.GetAsync(documentKey); + Assert.IsNotNull(updatedContent); + Assert.AreEqual("Updated Title", updatedContent.GetValue("title", "en-US")); + Assert.AreEqual("Updated Description", updatedContent.GetValue("description", "en-US")); + } + + [Test] + public async Task PatchDocument_PropertyAndVariant_UpdatesBoth() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Arrange - Create language + var langEnUs = new LanguageBuilder() + .WithCultureInfo("en-US") + .Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + + // Arrange - Create content type with properties and culture variation + var contentType = new ContentTypeBuilder() + .WithAlias("propertyAndVariantTest") + .WithName("Property And Variant Test") + .WithContentVariation(ContentVariation.Culture) + .AddPropertyType() + .WithAlias("title") + .WithName("Title") + .WithDataTypeId(Constants.DataTypes.Textbox) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox) + .WithValueStorageType(ValueStorageType.Nvarchar) + .WithVariations(ContentVariation.Culture) + .Done() + .WithAllowAsRoot(true) + .Build(); + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create document with en-US variant using ContentBuilder and ContentService + var content = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", "Original Name") + .Build(); + + // Set property value directly + content.SetValue("title", "Original Title", culture: "en-US"); + ContentService.Save(content); + + var documentKey = content.Key; + + // Patch both name and property for en-US + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "$.variants[?(@.culture == 'en-US' && @.segment == null)].name", + Value = "Updated Name" + }, + new PatchOperationRequestModel + { + Op = "replace", + Path = "$.values[?(@.alias == 'title' && @.culture == 'en-US' && @.segment == null)].value", + Value = "Updated Title" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify both name and property were updated + var updatedContent = await ContentEditingService.GetAsync(documentKey); + Assert.IsNotNull(updatedContent); + Assert.AreEqual("Updated Name", updatedContent.GetCultureName("en-US")); + Assert.AreEqual("Updated Title", updatedContent.GetValue("title", "en-US")); + } + + [Test] + public async Task PatchDocument_InvalidPropertyAlias_ReturnsUnprocessableEntity() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Arrange - Create language + var langEnUs = new LanguageBuilder() + .WithCultureInfo("en-US") + .Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + + // Arrange - Create content type with culture variation (but no custom properties) + var contentType = ContentTypeBuilder.CreateSimpleContentType("invalidPropertyTest", "Invalid Property Test"); + contentType.Variations = ContentVariation.Culture; + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create document with en-US variant + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = new List + { + new() { Name = "English Name", Culture = "en-US" }, + }, + }; + var createResponse = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + if (createResponse.Result.Content is null) + { + throw new InvalidOperationException("Failed to create test content"); + } + + var documentKey = createResponse.Result.Content.Key; + + // Try to patch non-existent property + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "$.values[?(@.alias == 'nonExistentProperty' && @.culture == 'en-US' && @.segment == null)].value", + Value = "Some Value" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + + // Assert + Assert.AreEqual(HttpStatusCode.UnprocessableEntity, response.StatusCode); + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathCultureExtractorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathCultureExtractorTests.cs new file mode 100644 index 000000000000..5cb332f5179f --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathCultureExtractorTests.cs @@ -0,0 +1,266 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.PropertyEditors.JsonPath; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.PropertyEditors.JsonPath; + +[TestFixture] +public class JsonPathCultureExtractorTests +{ + private JsonPathCultureExtractor _extractor = null!; + + [SetUp] + public void SetUp() + { + _extractor = new JsonPathCultureExtractor(); + } + + [Test] + public void ExtractCultures_SingleCultureWithSingleQuotes_ReturnsCulture() + { + // Arrange + var path = "$.values[?(@.alias == 'title' && @.culture == 'en-US')]"; + + // Act + var cultures = _extractor.ExtractCultures(path); + + // Assert + Assert.That(cultures, Has.Count.EqualTo(1)); + Assert.That(cultures, Does.Contain("en-US")); + } + + [Test] + public void ExtractCultures_SingleCultureWithDoubleQuotes_ReturnsCulture() + { + // Arrange + var path = """$.values[?(@.alias == "title" && @.culture == "en-US")]"""; + + // Act + var cultures = _extractor.ExtractCultures(path); + + // Assert + Assert.That(cultures, Has.Count.EqualTo(1)); + Assert.That(cultures, Does.Contain("en-US")); + } + + [Test] + public void ExtractCultures_MultipleCultures_ReturnsAllCultures() + { + // Arrange + var path1 = "$.values[?(@.culture == 'en-US')]"; + var path2 = "$.values[?(@.culture == 'da-DK')]"; + + // Act + var cultures1 = _extractor.ExtractCultures(path1); + var cultures2 = _extractor.ExtractCultures(path2); + + // Assert + Assert.That(cultures1, Does.Contain("en-US")); + Assert.That(cultures2, Does.Contain("da-DK")); + } + + [Test] + public void ExtractCultures_NoCulture_ReturnsEmpty() + { + // Arrange + var path = "$.values[?(@.alias == 'title')]"; + + // Act + var cultures = _extractor.ExtractCultures(path); + + // Assert + Assert.That(cultures, Is.Empty); + } + + [Test] + public void ExtractCultures_EmptyString_ReturnsEmpty() + { + // Act + var cultures = _extractor.ExtractCultures(string.Empty); + + // Assert + Assert.That(cultures, Is.Empty); + } + + [Test] + public void ExtractSegments_SingleSegment_ReturnsSegment() + { + // Arrange + var path = "$.values[?(@.alias == 'price' && @.segment == 'premium')]"; + + // Act + var segments = _extractor.ExtractSegments(path); + + // Assert + Assert.That(segments, Has.Count.EqualTo(1)); + Assert.That(segments, Does.Contain("premium")); + } + + [Test] + public void ExtractSegments_NoSegment_ReturnsEmpty() + { + // Arrange + var path = "$.values[?(@.alias == 'title')]"; + + // Act + var segments = _extractor.ExtractSegments(path); + + // Assert + Assert.That(segments, Is.Empty); + } + + [Test] + public void ContainsInvariantCultureFilter_NullCulture_ReturnsTrue() + { + // Arrange + var path = "$.values[?(@.alias == 'title' && @.culture == null)]"; + + // Act + var containsInvariant = _extractor.ContainsInvariantCultureFilter(path); + + // Assert + Assert.That(containsInvariant, Is.True); + } + + [Test] + public void ContainsInvariantCultureFilter_CultureValue_ReturnsFalse() + { + // Arrange + var path = "$.values[?(@.alias == 'title' && @.culture == 'en-US')]"; + + // Act + var containsInvariant = _extractor.ContainsInvariantCultureFilter(path); + + // Assert + Assert.That(containsInvariant, Is.False); + } + + [Test] + public void ContainsInvariantCultureFilter_NoCultureFilter_ReturnsFalse() + { + // Arrange + var path = "$.values[?(@.alias == 'title')]"; + + // Act + var containsInvariant = _extractor.ContainsInvariantCultureFilter(path); + + // Assert + Assert.That(containsInvariant, Is.False); + } + + [Test] + public void ContainsNullSegmentFilter_NullSegment_ReturnsTrue() + { + // Arrange + var path = "$.values[?(@.alias == 'price' && @.segment == null)]"; + + // Act + var containsNullSegment = _extractor.ContainsNullSegmentFilter(path); + + // Assert + Assert.That(containsNullSegment, Is.True); + } + + [Test] + public void ContainsNullSegmentFilter_SegmentValue_ReturnsFalse() + { + // Arrange + var path = "$.values[?(@.alias == 'price' && @.segment == 'premium')]"; + + // Act + var containsNullSegment = _extractor.ContainsNullSegmentFilter(path); + + // Assert + Assert.That(containsNullSegment, Is.False); + } + + [Test] + public void ExtractCulturesFromOperations_MultipleOperations_ReturnsAllUniqueCultures() + { + // Arrange + var operations = new[] + { + "$.values[?(@.culture == 'en-US')]", + "$.values[?(@.culture == 'da-DK')]", + "$.values[?(@.culture == 'en-US')]" // Duplicate + }; + + // Act + var cultures = _extractor.ExtractCulturesFromOperations(operations); + + // Assert + Assert.That(cultures, Has.Count.EqualTo(2)); + Assert.That(cultures, Does.Contain("en-US")); + Assert.That(cultures, Does.Contain("da-DK")); + } + + [Test] + public void ExtractCulturesFromOperations_EmptyCollection_ReturnsEmpty() + { + // Act + var cultures = _extractor.ExtractCulturesFromOperations(Array.Empty()); + + // Assert + Assert.That(cultures, Is.Empty); + } + + [Test] + public void AnyOperationTargetsInvariantCulture_ContainsInvariant_ReturnsTrue() + { + // Arrange + var operations = new[] + { + "$.values[?(@.culture == 'en-US')]", + "$.values[?(@.culture == null)]" + }; + + // Act + var targetsInvariant = _extractor.AnyOperationTargetsInvariantCulture(operations); + + // Assert + Assert.That(targetsInvariant, Is.True); + } + + [Test] + public void AnyOperationTargetsInvariantCulture_NoInvariant_ReturnsFalse() + { + // Arrange + var operations = new[] + { + "$.values[?(@.culture == 'en-US')]", + "$.values[?(@.culture == 'da-DK')]" + }; + + // Act + var targetsInvariant = _extractor.AnyOperationTargetsInvariantCulture(operations); + + // Assert + Assert.That(targetsInvariant, Is.False); + } + + [Test] + public void ExtractCultures_CaseInsensitive_ReturnsUniqueCultures() + { + // Arrange + var path = "$.values[?(@.culture == 'en-US' || @.culture == 'EN-US')]"; + + // Act + var cultures = _extractor.ExtractCultures(path); + + // Assert - Should return 1 unique culture (case-insensitive comparison) + Assert.That(cultures, Has.Count.EqualTo(1)); + } + + [Test] + public void ExtractCultures_ComplexNestedPath_ExtractsCulture() + { + // Arrange + var path = "$.values[?(@.alias == 'contentBlocks')].value.contentData[?(@.key == 'guid')].values[?(@.alias == 'headline' && @.culture == 'en-US')]"; + + // Act + var cultures = _extractor.ExtractCultures(path); + + // Assert + Assert.That(cultures, Has.Count.EqualTo(1)); + Assert.That(cultures, Does.Contain("en-US")); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathEvaluatorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathEvaluatorTests.cs new file mode 100644 index 000000000000..b1a7001f5f29 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathEvaluatorTests.cs @@ -0,0 +1,256 @@ +using System.Text.Json; +using NUnit.Framework; +using Umbraco.Cms.Core.PropertyEditors.JsonPath; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.PropertyEditors.JsonPath; + +[TestFixture] +public class JsonPathEvaluatorTests +{ + private JsonPathEvaluator _evaluator = null!; + + [SetUp] + public void SetUp() + { + _evaluator = new JsonPathEvaluator(); + } + + [Test] + public void Select_SimpleProperty_ReturnsMatchingElement() + { + // Arrange + var json = """ + { + "name": "Test Document", + "title": "Test Title" + } + """; + var doc = JsonDocument.Parse(json); + + // Act + var results = _evaluator.Select(doc, "$.name"); + + // Assert + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].GetString(), Is.EqualTo("Test Document")); + } + + [Test] + public void Select_ArrayFilterByProperty_ReturnsMatchingElements() + { + // Arrange + var json = """ + { + "values": [ + { "alias": "title", "value": "Title Value" }, + { "alias": "description", "value": "Description Value" } + ] + } + """; + var doc = JsonDocument.Parse(json); + + // Act + var results = _evaluator.Select(doc, "$.values[?(@.alias == 'title')]"); + + // Assert + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].GetProperty("value").GetString(), Is.EqualTo("Title Value")); + } + + [Test] + public void Select_MultipleFilters_ReturnsMatchingElements() + { + // Arrange + var json = """ + { + "values": [ + { "alias": "title", "culture": "en-US", "value": "English Title" }, + { "alias": "title", "culture": "da-DK", "value": "Danish Title" }, + { "alias": "description", "culture": "en-US", "value": "English Description" } + ] + } + """; + var doc = JsonDocument.Parse(json); + + // Act + var results = _evaluator.Select(doc, "$.values[?(@.alias == 'title' && @.culture == 'en-US')]"); + + // Assert + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].GetProperty("value").GetString(), Is.EqualTo("English Title")); + } + + [Test] + public void Select_NestedPath_ReturnsMatchingElements() + { + // Arrange + var json = """ + { + "values": [ + { + "alias": "contentBlocks", + "value": { + "contentData": [ + { "key": "12345678-1234-1234-1234-123456789012", "values": [{ "alias": "headline", "value": "Block Headline" }] } + ] + } + } + ] + } + """; + var doc = JsonDocument.Parse(json); + + // Act + var results = _evaluator.Select(doc, "$.values[?(@.alias == 'contentBlocks')].value.contentData[?(@.key == '12345678-1234-1234-1234-123456789012')].values[?(@.alias == 'headline')]"); + + // Assert + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].GetProperty("value").GetString(), Is.EqualTo("Block Headline")); + } + + [Test] + public void Select_NoMatches_ReturnsEmptyList() + { + // Arrange + var json = """ + { + "values": [ + { "alias": "title", "value": "Title Value" } + ] + } + """; + var doc = JsonDocument.Parse(json); + + // Act + var results = _evaluator.Select(doc, "$.values[?(@.alias == 'nonexistent')]"); + + // Assert + Assert.That(results, Is.Empty); + } + + [Test] + public void SelectSingle_ReturnsFirstMatch() + { + // Arrange + var json = """ + { + "values": [ + { "alias": "title", "value": "First" }, + { "alias": "title", "value": "Second" } + ] + } + """; + var doc = JsonDocument.Parse(json); + + // Act + var result = _evaluator.SelectSingle(doc, "$.values[?(@.alias == 'title')]"); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result!.Value.GetProperty("value").GetString(), Is.EqualTo("First")); + } + + [Test] + public void SelectSingle_NoMatches_ReturnsNull() + { + // Arrange + var json = """ + { + "values": [] + } + """; + var doc = JsonDocument.Parse(json); + + // Act + var result = _evaluator.SelectSingle(doc, "$.values[?(@.alias == 'title')]"); + + // Assert + Assert.That(result, Is.Null); + } + + [Test] + public void Exists_MatchFound_ReturnsTrue() + { + // Arrange + var json = """ + { + "values": [ + { "alias": "title", "value": "Title Value" } + ] + } + """; + var doc = JsonDocument.Parse(json); + + // Act + var exists = _evaluator.Exists(doc, "$.values[?(@.alias == 'title')]"); + + // Assert + Assert.That(exists, Is.True); + } + + [Test] + public void Exists_NoMatch_ReturnsFalse() + { + // Arrange + var json = """ + { + "values": [] + } + """; + var doc = JsonDocument.Parse(json); + + // Act + var exists = _evaluator.Exists(doc, "$.values[?(@.alias == 'title')]"); + + // Assert + Assert.That(exists, Is.False); + } + + [Test] + public void IsValidExpression_ValidExpression_ReturnsTrue() + { + // Act + var isValid = _evaluator.IsValidExpression("$.values[?(@.alias == 'title')]"); + + // Assert + Assert.That(isValid, Is.True); + } + + [Test] + public void IsValidExpression_InvalidExpression_ReturnsFalse() + { + // Act + var isValid = _evaluator.IsValidExpression("$.values[?(@.alias == "); + + // Assert + Assert.That(isValid, Is.False); + } + + [Test] + public void IsValidExpression_EmptyString_ReturnsFalse() + { + // Act + var isValid = _evaluator.IsValidExpression(string.Empty); + + // Assert + Assert.That(isValid, Is.False); + } + + [Test] + public void Select_NullDocument_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => _evaluator.Select(null!, "$.name")); + } + + [Test] + public void Select_EmptyExpression_ThrowsArgumentException() + { + // Arrange + var json = "{}"; + var doc = JsonDocument.Parse(json); + + // Act & Assert + Assert.Throws(() => _evaluator.Select(doc, string.Empty)); + } +} From ab7514ebcb1fac1caf2558a273a04b394fb37e93 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 27 Jan 2026 16:58:49 +0100 Subject: [PATCH 04/39] Fix ManagementApiTest following PR 20820 --- .../ManagementApi/ManagementApiTest.cs | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/ManagementApiTest.cs b/tests/Umbraco.Tests.Integration/ManagementApi/ManagementApiTest.cs index 598cfc015056..4065f201f67c 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/ManagementApiTest.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/ManagementApiTest.cs @@ -32,8 +32,7 @@ namespace Umbraco.Cms.Tests.Integration.ManagementApi; public abstract class ManagementApiTest : UmbracoTestServerTestBase where T : ManagementApiControllerBase { - - private static readonly Dictionary _tokenCache = new(); + private static readonly Dictionary _tokenCache = new(); private static readonly SHA256 _sha256 = SHA256.Create(); protected abstract Expression> MethodSelector { get; set; } @@ -126,8 +125,9 @@ protected async Task AuthenticateClientAsync(HttpClient client, Func(); + CookieContainer cookies = new CookieContainer(); + foreach (var cookieHeader in tokenResponse.Headers.GetValues("Set-Cookie")) + { + cookies.SetCookies(tokenResponse.RequestMessage!.RequestUri!, cookieHeader); + } + + string cookieTokenValue = cookies.GetCookies(tokenResponse.RequestMessage!.RequestUri!).FirstOrDefault(c => c.Name == "__Host-umbAccessToken")!.Value; - client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenModel.AccessToken); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "[redacted]"); // Cache the token if cache key provided if (!string.IsNullOrEmpty(cacheKey)) { - _tokenCache[cacheKey] = tokenModel; + _tokenCache[cacheKey] = cookieTokenValue; } } - - private class TokenModel - { - [JsonPropertyName("access_token")] - public string AccessToken { get; set; } - } } From cfc1bd919d07e3cd931e893366206ddc51e34287 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 27 Jan 2026 17:38:23 +0100 Subject: [PATCH 05/39] Segment suport for properties --- .../Patchers/DocumentPatcher.cs | 35 ++++++++ .../Document/PatchDocumentControllerTests.cs | 79 ++++++++++++++++++- .../JsonPath/JsonPathEvaluatorTests.cs | 33 ++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs index fa736824edc4..bbf6de8f7515 100644 --- a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs +++ b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs @@ -6,6 +6,7 @@ using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.PropertyEditors.JsonPath; using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Patchers; @@ -148,6 +149,40 @@ public async Task> A return Attempt.FailWithStatus(ContentPatchingOperationStatus.PropertyTypeNotFound, default(ContentUpdateModel)!); } + // Ensure variant exists for this culture/segment combination + // This is required for segment variation support + var key = (culture, segment); + if (!variants.ContainsKey(key)) + { + // Get the current name from the content + var currentName = content.GetCultureName(culture) ?? string.Empty; + + variants[key] = new VariantModel + { + Culture = culture, + Segment = segment, + Name = currentName + }; + } + + // If content type varies by segment and we're updating a specific segment, + // ensure we also have a default (null segment) variant to satisfy validation + if (segment != null && contentType.VariesBySegment()) + { + var defaultKey = (culture, (string?)null); + if (!variants.ContainsKey(defaultKey)) + { + var currentName = content.GetCultureName(culture) ?? string.Empty; + + variants[defaultKey] = new VariantModel + { + Culture = culture, + Segment = null, + Name = currentName + }; + } + } + if (operation.Op == PatchOperationType.Replace || operation.Op == PatchOperationType.Add) { properties.Add(new PropertyValueModel diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs index 266f12dd9f85..cb5ae8d70e16 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs @@ -11,11 +11,9 @@ using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Builders.Extensions; -using Umbraco.Cms.Tests.Common.Testing; namespace Umbraco.Cms.Tests.Integration.ManagementApi.Document; -[NonParallelizable] public class PatchDocumentControllerTests : ManagementApiUserGroupTestBase { private IContentEditingService ContentEditingService => GetRequiredService(); @@ -621,4 +619,81 @@ public async Task PatchDocument_InvalidPropertyAlias_ReturnsUnprocessableEntity( // Assert Assert.AreEqual(HttpStatusCode.UnprocessableEntity, response.StatusCode); } + + [Test] + public async Task PatchDocument_SegmentProperty_UpdatesCorrectSegment() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Arrange - Create language + var langEnUs = new LanguageBuilder() + .WithCultureInfo("en-US") + .Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + + // Arrange - Create content type with segment variation property + var contentType = new ContentTypeBuilder() + .WithAlias("segmentTest") + .WithName("Segment Test") + .WithContentVariation(ContentVariation.CultureAndSegment) // Content type must support segments + .AddPropertyType() + .WithAlias("price") + .WithName("Price") + .WithDataTypeId(Constants.DataTypes.Textbox) + .WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox) + .WithValueStorageType(ValueStorageType.Nvarchar) + .WithVariations(ContentVariation.CultureAndSegment) + .Done() + .WithAllowAsRoot(true) + .Build(); + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create document with en-US variant using ContentBuilder and ContentService + var content = new ContentBuilder() + .WithContentType(contentType) + .WithCultureName("en-US", "English Name") + .Build(); + + // Set property values for different segments (including default segment) + content.SetValue("price", "75", culture: "en-US", segment: null); // Default segment + content.SetValue("price", "100", culture: "en-US", segment: "standard"); + content.SetValue("price", "150", culture: "en-US", segment: "premium"); + ContentService.Save(content); + + var documentKey = content.Key; + + // Patch only premium segment + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "$.values[?(@.alias == 'price' && @.culture == 'en-US' && @.segment == 'premium')].value", + Value = "200" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + + // Assert + if (response.StatusCode != HttpStatusCode.OK) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Error Response: {errorContent}"); + } + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify only premium segment was updated + var updatedContent = await ContentEditingService.GetAsync(documentKey); + Assert.IsNotNull(updatedContent); + Assert.AreEqual("200", updatedContent.GetValue("price", "en-US", "premium")); + Assert.AreEqual("100", updatedContent.GetValue("price", "en-US", "standard")); // Unchanged + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathEvaluatorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathEvaluatorTests.cs index b1a7001f5f29..70d370d30ed7 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathEvaluatorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathEvaluatorTests.cs @@ -253,4 +253,37 @@ public void Select_EmptyExpression_ThrowsArgumentException() // Act & Assert Assert.Throws(() => _evaluator.Select(doc, string.Empty)); } + + [Test] + public void Select_ThreeFiltersWithSegment_ReturnsMatchingElements() + { + // Arrange + var json = """ + { + "values": [ + { "alias": "price", "culture": "en-US", "segment": "standard", "value": "100" }, + { "alias": "price", "culture": "en-US", "segment": "premium", "value": "150" }, + { "alias": "price", "culture": "da-DK", "segment": "premium", "value": "1000" } + ] + } + """; + var doc = JsonDocument.Parse(json); + + // Act + var results = _evaluator.Select(doc, "$.values[?(@.alias == 'price' && @.culture == 'en-US' && @.segment == 'premium')]"); + + // Assert + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].GetProperty("value").GetString(), Is.EqualTo("150")); + } + + [Test] + public void IsValidExpression_ThreeFiltersWithSegment_ReturnsTrue() + { + // Act + var isValid = _evaluator.IsValidExpression("$.values[?(@.alias == 'price' && @.culture == 'en-US' && @.segment == 'premium')].value"); + + // Assert + Assert.That(isValid, Is.True); + } } From 6a31bc256e1a6bf62f1c067f82c89a6a6297d7f0 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 27 Jan 2026 17:49:02 +0100 Subject: [PATCH 06/39] Verify non existing and trashed document patch behaviour --- .../Document/PatchDocumentControllerTests.cs | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs index cb5ae8d70e16..6aa94a88907f 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs @@ -696,4 +696,82 @@ public async Task PatchDocument_SegmentProperty_UpdatesCorrectSegment() Assert.AreEqual("200", updatedContent.GetValue("price", "en-US", "premium")); Assert.AreEqual("100", updatedContent.GetValue("price", "en-US", "standard")); // Unchanged } + + [Test] + public async Task PatchDocument_DocumentInRecycleBin_AllowsPatch() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Create simple content type + var contentType = new ContentTypeBuilder() + .WithAlias("simpleDoc") + .WithName("Simple Document") + .WithContentVariation(ContentVariation.Nothing) + .WithAllowAsRoot(true) + .Build(); + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create and immediately delete document (move to recycle bin) + var content = new ContentBuilder() + .WithContentType(contentType) + .Build(); + ContentService.Save(content); + ContentService.MoveToRecycleBin(content); + + var documentKey = content.Key; + + // Try to patch document in recycle bin + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "$.variants[?(@.culture == null && @.segment == null)].name", + Value = "Updated Name" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + + // Assert - Umbraco allows patching documents in recycle bin (they can be edited before permanent deletion) + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + } + + [Test] + public async Task PatchDocument_NonExistentDocument_ReturnsNotFound() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + var nonExistentKey = Guid.NewGuid(); + + // Try to patch non-existent document + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = "$.variants[?(@.culture == null && @.segment == null)].name", + Value = "Updated Name" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{nonExistentKey}", httpContent); + + // Assert + Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode); + } } From f27f536f77083571c843d4a731d74b73d9daefd6 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Sun, 1 Feb 2026 17:17:00 +0100 Subject: [PATCH 07/39] Mostly working approuch for nested properties --- .../Document/PatchDocumentController.cs | 14 +- .../DocumentEditingPresentationFactory.cs | 87 ++++- .../IDocumentEditingPresentationFactory.cs | 9 + .../Patchers/DocumentPatcher.cs | 194 ++-------- .../JsonPath/JsonPathEvaluator.cs | 175 +++++++++ .../Document/PatchDocumentControllerTests.cs | 221 ++++++++++- .../JsonPath/JsonPathEvaluatorTests.cs | 364 ++++++++++++++++++ 7 files changed, 895 insertions(+), 169 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs index e7e20588b2bd..7d0e1eaab9b5 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs @@ -21,19 +21,22 @@ public class PatchDocumentController : PatchDocumentControllerBase private readonly DocumentPatcher _documentPatcher; private readonly IDocumentEditingPresentationFactory _presentationFactory; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; + private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; public PatchDocumentController( IAuthorizationService authorizationService, IContentEditingService contentEditingService, DocumentPatcher documentPatcher, IDocumentEditingPresentationFactory presentationFactory, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + IDocumentEditingPresentationFactory documentEditingPresentationFactory) : base(authorizationService) { _contentEditingService = contentEditingService; _documentPatcher = documentPatcher; _presentationFactory = presentationFactory; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; + _documentEditingPresentationFactory = documentEditingPresentationFactory; } [HttpPatch("{id:guid}")] @@ -50,10 +53,11 @@ public async Task Patch( => await HandleRequest(id, requestModel, async () => { // Map request model to domain model + // todo: dont use intermitent model as patching happens in the api layer => make patcher work with PatchDocumentRequestModel directly ContentPatchModel patchModel = _presentationFactory.MapPatchModel(requestModel); - // Apply PATCH operations to create an update model - Attempt patchResult = + // Apply PATCH operations to create an update request model + Attempt patchResult = await _documentPatcher.ApplyPatchAsync(id, patchModel, CurrentUserKey(_backOfficeSecurityAccessor)); if (!patchResult.Success) @@ -61,9 +65,11 @@ public async Task Patch( return ContentPatchingOperationStatusResult(patchResult.Status); } + ContentUpdateModel contentUpdateModel = _documentEditingPresentationFactory.MapUpdateModel(patchResult.Result); + // Use the standard update method to save the patched content Attempt updateResult = - await _contentEditingService.UpdateAsync(id, patchResult.Result!, CurrentUserKey(_backOfficeSecurityAccessor)); + await _contentEditingService.UpdateAsync(id, contentUpdateModel, CurrentUserKey(_backOfficeSecurityAccessor)); return updateResult.Success ? Ok() diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs index 06453ebf8263..35cfd7c94f60 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs @@ -1,10 +1,29 @@ -using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Factories; internal sealed class DocumentEditingPresentationFactory : ContentEditingPresentationFactory, IDocumentEditingPresentationFactory { + private readonly PropertyEditorCollection _propertyEditorCollection; + private readonly IDataValueEditorFactory _dataValueEditorFactory; + private readonly ITemplateService _templateService; + + public DocumentEditingPresentationFactory( + PropertyEditorCollection propertyEditorCollection, + IDataValueEditorFactory dataValueEditorFactory, + ITemplateService templateService) + { + _propertyEditorCollection = propertyEditorCollection; + _dataValueEditorFactory = dataValueEditorFactory; + _templateService = templateService; + } + public ContentCreateModel MapCreateModel(CreateDocumentRequestModel requestModel) { ContentCreateModel model = MapContentEditingModel(requestModel); @@ -19,6 +38,72 @@ public ContentCreateModel MapCreateModel(CreateDocumentRequestModel requestModel public ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel) => MapUpdateContentModel(requestModel); + public async Task CreateUpdateRequestModelAsync(IContent content) + { + // Map values (similar to ContentMapDefinition.MapValueViewModels) + var values = MapValuesToRequestModel(content.Properties); + + // Map variants (culture/segment with name) + var variants = MapVariantsToRequestModel(content); + + // Map template + Guid? templateKey = content.TemplateId.HasValue + ? (await _templateService.GetAsync(content.TemplateId.Value))?.Key + : null; + + return new UpdateDocumentRequestModel + { + Values = values, + Variants = variants, + Template = templateKey.HasValue ? new ReferenceByIdModel { Id = templateKey.Value } : null + }; + } + + private DocumentValueModel[] MapValuesToRequestModel(IPropertyCollection properties) + { + Dictionary missingPropertyEditors = []; + return properties + .SelectMany(property => property + .Values + .Select(propertyValue => + { + IDataEditor? propertyEditor = _propertyEditorCollection[property.PropertyType.PropertyEditorAlias]; + if (propertyEditor is null && !missingPropertyEditors.TryGetValue(property.PropertyType.PropertyEditorAlias, out propertyEditor)) + { + // Cache missing property editors to avoid creating multiple instances + propertyEditor = new MissingPropertyEditor(property.PropertyType.PropertyEditorAlias, _dataValueEditorFactory); + missingPropertyEditors[property.PropertyType.PropertyEditorAlias] = propertyEditor; + } + + return new DocumentValueModel + { + Culture = propertyValue.Culture, + Segment = propertyValue.Segment, + Alias = property.Alias, + Value = propertyEditor.GetValueEditor().ToEditor(property, propertyValue.Culture, propertyValue.Segment), + }; + })) + .WhereNotNull() + .ToArray(); + } + + private DocumentVariantRequestModel[] MapVariantsToRequestModel(IContent content) + { + IPropertyValue[] propertyValues = content.Properties.SelectMany(propertyCollection => propertyCollection.Values).ToArray(); + var cultures = content.AvailableCultures.DefaultIfEmpty(null).ToArray(); + // The default segment (null) must always be included + var segments = propertyValues.Select(property => property.Segment).Union([null]).Distinct().ToArray(); + + return cultures + .SelectMany(culture => segments.Select(segment => new DocumentVariantRequestModel + { + Culture = culture, + Segment = segment, + Name = content.GetCultureName(culture) ?? string.Empty, + })) + .ToArray(); + } + public ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel) { var cultureExtractor = new Umbraco.Cms.Core.PropertyEditors.JsonPath.JsonPathCultureExtractor(); diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs index 0a4c28f4a1f3..33c6de8e7ad4 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs @@ -1,4 +1,5 @@ using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; namespace Umbraco.Cms.Api.Management.Factories; @@ -9,6 +10,14 @@ public interface IDocumentEditingPresentationFactory ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel); + /// + /// Creates an UpdateDocumentRequestModel from IContent. + /// Converts property values using ToEditor transformation. + /// + /// The content to convert. + /// An UpdateDocumentRequestModel representing the content. + Task CreateUpdateRequestModelAsync(IContent content); + ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel); ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel); diff --git a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs index bbf6de8f7515..39d68c686e87 100644 --- a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs +++ b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs @@ -1,12 +1,12 @@ -using System.Text.Json; -using System.Text.RegularExpressions; +using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.OperationStatus; +using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.PropertyEditors.JsonPath; +using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; -using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Patchers; @@ -18,17 +18,23 @@ public class DocumentPatcher private readonly IContentEditingService _contentEditingService; private readonly IContentTypeService _contentTypeService; private readonly ILanguageService _languageService; + private readonly IJsonSerializer _jsonSerializer; + private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; private readonly JsonPathEvaluator _jsonPathEvaluator; private readonly JsonPathCultureExtractor _cultureExtractor; public DocumentPatcher( IContentEditingService contentEditingService, IContentTypeService contentTypeService, - ILanguageService languageService) + ILanguageService languageService, + IJsonSerializer jsonSerializer, + IDocumentEditingPresentationFactory documentEditingPresentationFactory) { _contentEditingService = contentEditingService; _contentTypeService = contentTypeService; _languageService = languageService; + _jsonSerializer = jsonSerializer; + _documentEditingPresentationFactory = documentEditingPresentationFactory; _jsonPathEvaluator = new JsonPathEvaluator(); _cultureExtractor = new JsonPathCultureExtractor(); } @@ -41,7 +47,7 @@ public DocumentPatcher( /// The patch model containing operations and affected cultures/segments. /// The user performing the operation. /// An attempt containing the update model or an error status. - public async Task> ApplyPatchAsync( + public async Task> ApplyPatchAsync( Guid documentKey, ContentPatchModel patchModel, Guid userKey) @@ -51,14 +57,14 @@ public async Task> A { if (!_jsonPathEvaluator.IsValidExpression(operation.Path)) { - return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(ContentUpdateModel)!); + return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); } // Validate that replace/add operations have a value if ((operation.Op == PatchOperationType.Replace || operation.Op == PatchOperationType.Add) && operation.Value is null) { - return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(ContentUpdateModel)!); + return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); } } @@ -66,172 +72,38 @@ public async Task> A IContent? content = await _contentEditingService.GetAsync(documentKey); if (content is null) { - return Attempt.FailWithStatus(ContentPatchingOperationStatus.NotFound, default(ContentUpdateModel)!); + return Attempt.FailWithStatus(ContentPatchingOperationStatus.NotFound, default(UpdateDocumentRequestModel)!); } - // Validate cultures (cultures are already extracted in the patch model) - if (patchModel.AffectedCultures.Any()) - { - IEnumerable allLanguages = await _languageService.GetAllAsync(); - var availableCultures = allLanguages.Select(l => l.IsoCode).ToHashSet(StringComparer.OrdinalIgnoreCase); - var invalidCultures = patchModel.AffectedCultures.Where(c => !availableCultures.Contains(c)).ToArray(); - if (invalidCultures.Any()) - { - return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidCulture, default(ContentUpdateModel)!); - } - } - - // Get content type - IContentType? contentType = _contentTypeService.Get(content.ContentTypeId); - if (contentType is null) - { - return Attempt.FailWithStatus(ContentPatchingOperationStatus.ContentTypeNotFound, default(ContentUpdateModel)!); - } - - // Apply operations to build update model - var variants = new Dictionary<(string? Culture, string? Segment), VariantModel>(); - var properties = new List(); - Guid? templateKey = null; + // Convert to JSON as if a client would have sent the full payload + UpdateDocumentRequestModel unModifiedUpdateModel = await _documentEditingPresentationFactory.CreateUpdateRequestModelAsync(content); + var currentJsonString = _jsonSerializer.Serialize(unModifiedUpdateModel); + // Apply each PATCH operation to the JSON foreach (PatchOperationModel operation in patchModel.Operations) { - // Parse the JSONPath to determine what to update - if (operation.Path.Contains("$.variants")) + try { - // Variant operation (name update) - var culture = _cultureExtractor.ExtractCultures(operation.Path).FirstOrDefault(); - var segment = _cultureExtractor.ExtractSegments(operation.Path).FirstOrDefault(); - - if (operation.Op != PatchOperationType.Replace && operation.Op != PatchOperationType.Add) - { - continue; - } - - var key = (culture, segment); - if (!variants.ContainsKey(key)) - { - variants[key] = new VariantModel - { - Culture = culture, - Segment = segment, - Name = string.Empty - }; - } - - if (operation.Path.Contains(".name")) - { - variants[key].Name = operation.Value?.ToString() ?? string.Empty; - } - } - else if (operation.Path.Contains("$.values")) - { - // Property value operation - var culture = _cultureExtractor.ExtractCultures(operation.Path).FirstOrDefault(); - var segment = _cultureExtractor.ExtractSegments(operation.Path).FirstOrDefault(); - - // Extract property alias from the path (simplified parsing) - // Path format: $.values[?(@.alias == 'propertyAlias' && ...)].value - Match aliasMatch = Regex.Match( + currentJsonString = _jsonPathEvaluator.ApplyOperation( + currentJsonString, + operation.Op, operation.Path, - @"@\.alias\s*==\s*['""]([^'""]+)['""]"); - - if (!aliasMatch.Success) - { - return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(ContentUpdateModel)!); - } - - var propertyAlias = aliasMatch.Groups[1].Value; - - // Validate property exists on the content type - IPropertyType? propertyType = contentType.CompositionPropertyTypes.FirstOrDefault(pt => pt.Alias == propertyAlias); - if (propertyType is null) - { - return Attempt.FailWithStatus(ContentPatchingOperationStatus.PropertyTypeNotFound, default(ContentUpdateModel)!); - } - - // Ensure variant exists for this culture/segment combination - // This is required for segment variation support - var key = (culture, segment); - if (!variants.ContainsKey(key)) - { - // Get the current name from the content - var currentName = content.GetCultureName(culture) ?? string.Empty; - - variants[key] = new VariantModel - { - Culture = culture, - Segment = segment, - Name = currentName - }; - } - - // If content type varies by segment and we're updating a specific segment, - // ensure we also have a default (null segment) variant to satisfy validation - if (segment != null && contentType.VariesBySegment()) - { - var defaultKey = (culture, (string?)null); - if (!variants.ContainsKey(defaultKey)) - { - var currentName = content.GetCultureName(culture) ?? string.Empty; - - variants[defaultKey] = new VariantModel - { - Culture = culture, - Segment = null, - Name = currentName - }; - } - } - - if (operation.Op == PatchOperationType.Replace || operation.Op == PatchOperationType.Add) - { - properties.Add(new PropertyValueModel - { - Alias = propertyAlias, - Value = operation.Value, - Culture = culture, - Segment = segment, - }); - } - else if (operation.Op == PatchOperationType.Remove) - { - properties.Add(new PropertyValueModel - { - Alias = propertyAlias, - Value = null, - Culture = culture, - Segment = segment, - }); - } + operation.Value); } - else if (operation.Path.Contains("$.template")) + catch (InvalidOperationException) { - // Template operation - if (operation.Op == PatchOperationType.Replace || operation.Op == PatchOperationType.Add) - { - if (operation.Value is JsonElement jsonElement && jsonElement.TryGetProperty("id", out JsonElement idElement)) - { - templateKey = idElement.GetGuid(); - } - else if (operation.Value is Guid guid) - { - templateKey = guid; - } - } - else if (operation.Op == PatchOperationType.Remove) - { - templateKey = null; - } + // JSONPath matched no elements or other operation error + return Attempt.FailWithStatus( + ContentPatchingOperationStatus.InvalidOperation, + default(UpdateDocumentRequestModel)!); } } - var updateModel = new ContentUpdateModel - { - Variants = variants.Values.ToArray(), - Properties = properties.ToArray(), - TemplateKey = templateKey, - }; + // Deserialize the modified JSON back to UpdateDocumentRequestModel + UpdateDocumentRequestModel? modifiedUpdateModel = _jsonSerializer.Deserialize(currentJsonString); - return Attempt.SucceedWithStatus(ContentPatchingOperationStatus.Success, updateModel); + return modifiedUpdateModel is not null + ? Attempt.SucceedWithStatus(ContentPatchingOperationStatus.Success, modifiedUpdateModel) + : Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); } } diff --git a/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathEvaluator.cs b/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathEvaluator.cs index 232c4e7c637f..cc4aec7299b8 100644 --- a/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathEvaluator.cs +++ b/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathEvaluator.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using System.Text.Json.Nodes; using JsonCons.JsonPath; +using Umbraco.Cms.Core.Models.ContentEditing; namespace Umbraco.Cms.Core.PropertyEditors.JsonPath; @@ -86,4 +88,177 @@ public bool IsValidExpression(string pathExpression) return false; } } + + /// + /// Applies a PATCH operation (replace/add/remove) to a JSON string at the specified JSONPath. + /// Returns a new JSON string with the modification applied. + /// + /// The JSON string to modify. + /// The operation type (Replace, Add, Remove). + /// The JSONPath expression identifying the target location. + /// The value to set (required for Replace and Add operations). + /// The modified JSON string. + /// Thrown when the path matches no elements for Replace/Remove operations. + public string ApplyOperation(string jsonString, PatchOperationType op, string path, object? value) + { + if (string.IsNullOrWhiteSpace(jsonString)) + { + throw new ArgumentNullException(nameof(jsonString)); + } + + if (string.IsNullOrWhiteSpace(path)) + { + throw new ArgumentException("Path expression cannot be null or empty.", nameof(path)); + } + + // Parse to mutable JsonNode + JsonNode? rootNode = JsonNode.Parse(jsonString); + if (rootNode == null) + { + throw new InvalidOperationException("Failed to parse JSON string."); + } + + // Use JsonCons to find matching nodes (path-value pairs) + using var doc = JsonDocument.Parse(jsonString); + var selector = JsonSelector.Parse(path); + var nodes = selector.SelectNodes(doc.RootElement); + var nodeList = nodes.ToList(); + + if (nodeList.Count == 0 && op != PatchOperationType.Add) + { + throw new InvalidOperationException($"JSONPath expression '{path}' matched no elements."); + } + + // Convert value to JsonNode + JsonNode? valueNode = value != null + ? JsonSerializer.SerializeToNode(value) + : null; + + // Apply operation to each matched path + foreach (var node in nodeList) + { + var jsonPointer = node.Path.ToJsonPointer(); + ApplyOperationAtJsonPointer(rootNode, jsonPointer, op, valueNode); + } + + return rootNode.ToJsonString(); + } + + private void ApplyOperationAtJsonPointer(JsonNode rootNode, string jsonPointer, PatchOperationType op, JsonNode? valueNode) + { + // Parse JSON Pointer into segments + var segments = ParseJsonPointer(jsonPointer); + + if (segments.Length == 0) + { + throw new InvalidOperationException("Cannot modify root node directly."); + } + + // Navigate to the parent of the target node + JsonNode? current = rootNode; + JsonNode? parent = null; + object? lastKey = null; // string for object property, int for array index + + for (int i = 0; i < segments.Length; i++) + { + var segment = segments[i]; + parent = current; + + // Try to parse as array index + if (int.TryParse(segment, out int arrayIndex) && current is JsonArray) + { + lastKey = arrayIndex; + current = current?[arrayIndex]; + } + else + { + // Treat as object property name + lastKey = segment; + current = current?[segment]; + } + } + + if (parent == null || lastKey == null) + { + throw new InvalidOperationException("Cannot modify root node directly."); + } + + // Apply the operation + switch (op) + { + case PatchOperationType.Replace: + if (lastKey is string propertyName) + { + if (parent is JsonObject parentObj) + { + parentObj[propertyName] = valueNode?.DeepClone(); + } + } + else if (lastKey is int index) + { + if (parent is JsonArray parentArr) + { + parentArr[index] = valueNode?.DeepClone(); + } + } + break; + + case PatchOperationType.Add: + if (lastKey is string addPropertyName) + { + if (parent is JsonObject addParentObj) + { + addParentObj[addPropertyName] = valueNode?.DeepClone(); + } + } + else if (lastKey is int addIndex) + { + if (parent is JsonArray addParentArr) + { + addParentArr.Insert(addIndex, valueNode?.DeepClone()); + } + } + break; + + case PatchOperationType.Remove: + if (lastKey is string removePropertyName) + { + if (parent is JsonObject removeParentObj) + { + removeParentObj.Remove(removePropertyName); + } + } + else if (lastKey is int removeIndex) + { + if (parent is JsonArray removeParentArr) + { + removeParentArr.RemoveAt(removeIndex); + } + } + break; + } + } + + /// + /// Parses a JSON Pointer string (RFC 6901) into path segments. + /// + private static string[] ParseJsonPointer(string jsonPointer) + { + if (string.IsNullOrEmpty(jsonPointer) || jsonPointer == "/") + { + return Array.Empty(); + } + + // Remove leading slash and split + var pointer = jsonPointer.StartsWith("/") ? jsonPointer.Substring(1) : jsonPointer; + var segments = pointer.Split('/'); + + // Unescape JSON Pointer special characters (~1 -> /, ~0 -> ~) + for (int i = 0; i < segments.Length; i++) + { + segments[i] = segments[i].Replace("~1", "/").Replace("~0", "~"); + } + + return segments; + } } diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs index 6aa94a88907f..bf304d09ef7c 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs @@ -6,11 +6,15 @@ using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Tests.Common.Builders; using Umbraco.Cms.Tests.Common.Builders.Extensions; +using UmbracoDataType = Umbraco.Cms.Core.Models.DataType; namespace Umbraco.Cms.Tests.Integration.ManagementApi.Document; @@ -562,7 +566,7 @@ public async Task PatchDocument_PropertyAndVariant_UpdatesBoth() } [Test] - public async Task PatchDocument_InvalidPropertyAlias_ReturnsUnprocessableEntity() + public async Task PatchDocument_InvalidPropertyAlias_ReturnsBadRequest() { // Arrange - Authenticate as admin await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); @@ -598,6 +602,7 @@ public async Task PatchDocument_InvalidPropertyAlias_ReturnsUnprocessableEntity( var documentKey = createResponse.Result.Content.Key; // Try to patch non-existent property + // When JSONPath matches no elements, it returns BadRequest (400) not UnprocessableEntity (422) var patchModel = new PatchDocumentRequestModel { Operations = new[] @@ -616,8 +621,8 @@ public async Task PatchDocument_InvalidPropertyAlias_ReturnsUnprocessableEntity( httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); - // Assert - Assert.AreEqual(HttpStatusCode.UnprocessableEntity, response.StatusCode); + // Assert - Returns BadRequest (400) because JSONPath expression matches no elements + Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); } [Test] @@ -774,4 +779,214 @@ public async Task PatchDocument_NonExistentDocument_ReturnsNotFound() // Assert Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode); } + + [Test] + public async Task PatchDocument_BlockList_SingleBlock_UpdatesSingleProperty() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Create element type for blocks + var elementType = new ContentTypeBuilder() + .WithAlias("heroBlock") + .WithName("Hero Block") + .AddPropertyType() + .WithAlias("headline") + .WithName("Headline") + .WithDataTypeId(Constants.DataTypes.Textbox) + .Done() + .AddPropertyType() + .WithAlias("description") + .WithName("Description") + .WithDataTypeId(Constants.DataTypes.Textarea) + .Done() + .Build(); + elementType.IsElement = true; + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + // Create Block List data type + var propertyEditorCollection = GetRequiredService(); + var configurationEditorJsonSerializer = GetRequiredService(); + + var blockListDataType = new UmbracoDataType( + propertyEditorCollection[Constants.PropertyEditors.Aliases.BlockList], + configurationEditorJsonSerializer) + { + ConfigurationData = new Dictionary + { + { + "blocks", + new[] + { + new { contentElementTypeKey = elementType.Key } + } + } + }, + Name = "My Block List", + DatabaseType = ValueStorageType.Ntext, + ParentId = Constants.System.Root, + CreateDate = DateTime.UtcNow + }; + + var dataTypeService = GetRequiredService(); + await dataTypeService.CreateAsync(blockListDataType, Constants.Security.SuperUserKey); + + // Create content type with Block List property + var contentType = new ContentTypeBuilder() + .WithAlias("blockListPage") + .WithName("Block List Page") + .AddPropertyType() + .WithAlias("contentBlocks") + .WithName("Content Blocks") + .WithDataTypeId(blockListDataType.Id) + .Done() + .Build(); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create three blocks + var block1Key = Guid.NewGuid(); + var block2Key = Guid.NewGuid(); + var block3Key = Guid.NewGuid(); + + var jsonSerializer = GetRequiredService(); + var blockListValue = new BlockListValue + { + Layout = new Dictionary> + { + { + Constants.PropertyEditors.Aliases.BlockList, + new IBlockLayoutItem[] + { + new BlockListLayoutItem { ContentKey = block1Key }, + new BlockListLayoutItem { ContentKey = block2Key }, + new BlockListLayoutItem { ContentKey = block3Key } + } + } + }, + ContentData = new List + { + new BlockItemData + { + Key = block1Key, + ContentTypeKey = elementType.Key, + ContentTypeAlias = elementType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "headline", Value = "Block 1 Headline" }, + new BlockPropertyValue { Alias = "description", Value = "Block 1 Description" } + } + }, + new BlockItemData + { + Key = block2Key, + ContentTypeKey = elementType.Key, + ContentTypeAlias = elementType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "headline", Value = "Block 2 Headline" }, + new BlockPropertyValue { Alias = "description", Value = "Block 2 Description" } + } + }, + new BlockItemData + { + Key = block3Key, + ContentTypeKey = elementType.Key, + ContentTypeAlias = elementType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "headline", Value = "Block 3 Headline" }, + new BlockPropertyValue { Alias = "description", Value = "Block 3 Description" } + } + } + }, + SettingsData = new List(), + Expose = new List() + }; + + var blockListJson = jsonSerializer.Serialize(blockListValue); + + // Create document with Block List + var content = new ContentBuilder() + .WithContentType(contentType) + .WithName("My Blocks Document") + .WithCreatorId(Constants.Security.SuperUserId) + .Build(); + + content.SetValue("contentBlocks", blockListJson); + ContentService.Save(content); + var documentKey = content.Key; + + // Act - Patch only the headline of block 2 using nested JSONPath + // This demonstrates the TARGET API contract for Phase 8 implementation + // The JSONPath expression navigates through the nested structure: + // 1. Find the property with alias 'contentBlocks' + // 2. Navigate into its .value (the BlockListValue object) + // 3. Navigate into .contentData array + // 4. Find the block with the specific key (block2Key) + // 5. Navigate into its .values array + // 6. Find the property with alias 'headline' + // 7. Update its .value + // This enables minimal payload updates (~100 bytes) vs full replacement (~2KB) + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = $"$.values[?(@.alias == 'contentBlocks' && @.culture == null && @.segment == null)].value.contentData[?(@.key == '{block2Key}')].values[?(@.alias == 'headline')].value", + Value = "Updated Block 2 Headline" + } + } + }; + + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + + // Assert + if (response.StatusCode != HttpStatusCode.OK) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Error response: {response.StatusCode}"); + Console.WriteLine($"Error body: {errorContent}"); + } + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify only block 2's headline was updated + var updatedContent = ContentService.GetById(documentKey); + Assert.IsNotNull(updatedContent); + + var updatedBlockListJson = updatedContent.GetValue("contentBlocks"); + Assert.IsNotNull(updatedBlockListJson); + + var updatedBlockListValue = jsonSerializer.Deserialize(updatedBlockListJson); + Assert.IsNotNull(updatedBlockListValue); + + // Find block 2 in the content data + var block2Data = updatedBlockListValue.ContentData.FirstOrDefault(b => b.Key == block2Key); + Assert.IsNotNull(block2Data); + + // Verify block 2 headline was updated + var headlineValue = block2Data.Values.FirstOrDefault(v => v.Alias == "headline"); + Assert.IsNotNull(headlineValue); + Assert.AreEqual("Updated Block 2 Headline", headlineValue.Value?.ToString()); + + // Verify block 2 description was NOT updated + var descriptionValue = block2Data.Values.FirstOrDefault(v => v.Alias == "description"); + Assert.IsNotNull(descriptionValue); + Assert.AreEqual("Block 2 Description", descriptionValue.Value?.ToString()); + + // Verify block 1 and block 3 were NOT updated + var block1Data = updatedBlockListValue.ContentData.FirstOrDefault(b => b.Key == block1Key); + Assert.IsNotNull(block1Data); + Assert.AreEqual("Block 1 Headline", block1Data.Values.FirstOrDefault(v => v.Alias == "headline")?.Value?.ToString()); + Assert.AreEqual("Block 1 Description", block1Data.Values.FirstOrDefault(v => v.Alias == "description")?.Value?.ToString()); + + var block3Data = updatedBlockListValue.ContentData.FirstOrDefault(b => b.Key == block3Key); + Assert.IsNotNull(block3Data); + Assert.AreEqual("Block 3 Headline", block3Data.Values.FirstOrDefault(v => v.Alias == "headline")?.Value?.ToString()); + Assert.AreEqual("Block 3 Description", block3Data.Values.FirstOrDefault(v => v.Alias == "description")?.Value?.ToString()); + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathEvaluatorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathEvaluatorTests.cs index 70d370d30ed7..a16b0c5c3a74 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathEvaluatorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathEvaluatorTests.cs @@ -1,5 +1,7 @@ using System.Text.Json; +using System.Text.Json.Nodes; using NUnit.Framework; +using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.PropertyEditors.JsonPath; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.PropertyEditors.JsonPath; @@ -286,4 +288,366 @@ public void IsValidExpression_ThreeFiltersWithSegment_ReturnsTrue() // Assert Assert.That(isValid, Is.True); } + + [Test] + public void ApplyOperation_ReplaceSimpleProperty_UpdatesValue() + { + // Arrange + var json = """ + { + "name": "Original Name", + "title": "Original Title" + } + """; + + // Act + var result = _evaluator.ApplyOperation(json, PatchOperationType.Replace, "$.name", "Updated Name"); + + // Assert + var doc = JsonDocument.Parse(result); + Assert.That(doc.RootElement.GetProperty("name").GetString(), Is.EqualTo("Updated Name")); + Assert.That(doc.RootElement.GetProperty("title").GetString(), Is.EqualTo("Original Title")); + } + + [Test] + public void ApplyOperation_ReplaceArrayElementProperty_UpdatesValue() + { + // Arrange + var json = """ + { + "values": [ + { "alias": "title", "value": "Original Value" }, + { "alias": "description", "value": "Description Value" } + ] + } + """; + + // Act + var result = _evaluator.ApplyOperation(json, PatchOperationType.Replace, "$.values[?(@.alias == 'title')].value", "Updated Value"); + + // Assert + var doc = JsonDocument.Parse(result); + var values = doc.RootElement.GetProperty("values").EnumerateArray().ToList(); + Assert.That(values[0].GetProperty("value").GetString(), Is.EqualTo("Updated Value")); + Assert.That(values[1].GetProperty("value").GetString(), Is.EqualTo("Description Value")); + } + + [Test] + public void ApplyOperation_ReplaceNestedProperty_UpdatesValue() + { + // Arrange + var json = """ + { + "values": [ + { + "alias": "contentBlocks", + "value": { + "contentData": [ + { "key": "block-1", "values": [{ "alias": "headline", "value": "Original" }] }, + { "key": "block-2", "values": [{ "alias": "headline", "value": "Block 2 Headline" }] } + ] + } + } + ] + } + """; + + // Act + var result = _evaluator.ApplyOperation( + json, + PatchOperationType.Replace, + "$.values[?(@.alias == 'contentBlocks')].value.contentData[?(@.key == 'block-2')].values[?(@.alias == 'headline')].value", + "Updated Block 2 Headline"); + + // Assert + var doc = JsonDocument.Parse(result); + var contentBlocks = doc.RootElement.GetProperty("values").EnumerateArray().First(); + var contentData = contentBlocks.GetProperty("value").GetProperty("contentData").EnumerateArray().ToList(); + var block2 = contentData.First(b => b.GetProperty("key").GetString() == "block-2"); + var headline = block2.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(headline.GetProperty("value").GetString(), Is.EqualTo("Updated Block 2 Headline")); + + // Verify block 1 wasn't changed + var block1 = contentData.First(b => b.GetProperty("key").GetString() == "block-1"); + var block1Headline = block1.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(block1Headline.GetProperty("value").GetString(), Is.EqualTo("Original")); + } + + [Test] + public void ApplyOperation_NoMatches_ThrowsInvalidOperationException() + { + // Arrange + var json = """ + { + "values": [] + } + """; + + // Act & Assert + Assert.Throws(() => + _evaluator.ApplyOperation(json, PatchOperationType.Replace, "$.values[?(@.alias == 'nonexistent')].value", "New Value")); + } + + [Test] + public void Select_NullComparison_ReturnsMatchingElements() + { + // Arrange - Test null comparison in JSONPath filter + var json = """ + { + "values": [ + { "alias": "title", "culture": null, "segment": null, "value": "Invariant Value" }, + { "alias": "title", "culture": "en-US", "segment": null, "value": "English Value" } + ] + } + """; + var doc = JsonDocument.Parse(json); + + // Act - Filter by null culture + var results = _evaluator.Select(doc, "$.values[?(@.alias == 'title' && @.culture == null)]"); + + // Assert + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].GetProperty("value").GetString(), Is.EqualTo("Invariant Value")); + } + + [Test] + public void Select_MultipleNullComparisons_ReturnsMatchingElements() + { + // Arrange - Test multiple null comparisons + var json = """ + { + "values": [ + { "alias": "title", "culture": null, "segment": null, "value": "Invariant" }, + { "alias": "title", "culture": null, "segment": "premium", "value": "Invariant Premium" }, + { "alias": "title", "culture": "en-US", "segment": null, "value": "English" } + ] + } + """; + var doc = JsonDocument.Parse(json); + + // Act - Filter by both null culture and null segment + var results = _evaluator.Select(doc, "$.values[?(@.alias == 'title' && @.culture == null && @.segment == null)]"); + + // Assert + Assert.That(results, Has.Count.EqualTo(1)); + Assert.That(results[0].GetProperty("value").GetString(), Is.EqualTo("Invariant")); + } + + [Test] + public void ApplyOperation_ReplaceWithNullFilters_UpdatesValue() + { + // Arrange - Test replacement with null filters (exact scenario from Block List test) + var json = """ + { + "values": [ + { + "alias": "contentBlocks", + "culture": null, + "segment": null, + "value": { + "contentData": [ + { "key": "block-1", "values": [{ "alias": "headline", "value": "Block 1 Headline" }] }, + { "key": "block-2", "values": [{ "alias": "headline", "value": "Block 2 Headline" }] } + ] + } + } + ] + } + """; + + // Act + var result = _evaluator.ApplyOperation( + json, + PatchOperationType.Replace, + "$.values[?(@.alias == 'contentBlocks' && @.culture == null && @.segment == null)].value.contentData[?(@.key == 'block-2')].values[?(@.alias == 'headline')].value", + "Updated Block 2 Headline"); + + // Assert + var doc = JsonDocument.Parse(result); + var contentBlocks = doc.RootElement.GetProperty("values").EnumerateArray().First(); + var contentData = contentBlocks.GetProperty("value").GetProperty("contentData").EnumerateArray().ToList(); + + var block2 = contentData.First(b => b.GetProperty("key").GetString() == "block-2"); + var headline = block2.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(headline.GetProperty("value").GetString(), Is.EqualTo("Updated Block 2 Headline")); + + // Verify block 1 wasn't changed + var block1 = contentData.First(b => b.GetProperty("key").GetString() == "block-1"); + var block1Headline = block1.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(block1Headline.GetProperty("value").GetString(), Is.EqualTo("Block 1 Headline")); + } + + [Test] + public void ApplyOperation_RealBlockListStructure_UpdatesValue() + { + // Arrange - Use the exact JSON structure from the integration test + var blockKey = "25acc650-ab47-40ba-b31b-c5dc0a9eb5b1"; + var json = $$""" + { + "template" : null, + "values" : [ { + "culture" : null, + "segment" : null, + "alias" : "contentBlocks", + "value" : { + "contentData" : [ { + "contentTypeKey" : "98b8b6da-d9c1-47af-abd2-f99cf8150356", + "udi" : null, + "key" : "629e4129-3128-4367-9dac-d90edbfa68df", + "values" : [ { + "editorAlias" : "Umbraco.TextBox", + "culture" : null, + "segment" : null, + "alias" : "headline", + "value" : "Block 1 Headline" + } ] + }, { + "contentTypeKey" : "98b8b6da-d9c1-47af-abd2-f99cf8150356", + "udi" : null, + "key" : "{{blockKey}}", + "values" : [ { + "editorAlias" : "Umbraco.TextBox", + "culture" : null, + "segment" : null, + "alias" : "headline", + "value" : "Block 2 Headline" + } ] + } ], + "settingsData" : [ ], + "expose" : [ ], + "Layout" : { + "Umbraco.BlockList" : [ ] + } + } + } ], + "variants" : [ { + "culture" : null, + "segment" : null, + "name" : "My Blocks Document" + } ] + } + """; + + var path = $"$.values[?(@.alias == 'contentBlocks' && @.culture == null && @.segment == null)].value.contentData[?(@.key == '{blockKey}')].values[?(@.alias == 'headline')].value"; + + // Act + var result = _evaluator.ApplyOperation(json, PatchOperationType.Replace, path, "Updated Block 2 Headline"); + + // Assert + var doc = JsonDocument.Parse(result); + var values = doc.RootElement.GetProperty("values").EnumerateArray().First(); + var contentData = values.GetProperty("value").GetProperty("contentData").EnumerateArray().ToList(); + + // Find block 2 by key + var block2 = contentData.First(b => b.GetProperty("key").GetString() == blockKey); + var headline = block2.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(headline.GetProperty("value").GetString(), Is.EqualTo("Updated Block 2 Headline")); + + // Verify block 1 wasn't changed + var block1 = contentData.First(b => b.GetProperty("key").GetString() == "629e4129-3128-4367-9dac-d90edbfa68df"); + var block1Headline = block1.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(block1Headline.GetProperty("value").GetString(), Is.EqualTo("Block 1 Headline")); + } + + [Test] + public void JsonObject_ToString_ReturnsValidJson() + { + // This test verifies that JsonObject.ToString() returns valid JSON + // which is critical for BlockEditorValues.DeserializeAndClean + var json = """ + { + "contentData": [ + { "key": "block-1", "values": [{ "alias": "headline", "value": "Block 1 Headline" }] }, + { "key": "block-2", "values": [{ "alias": "headline", "value": "Block 2 Headline" }] } + ], + "settingsData": [], + "expose": [], + "Layout": { "Umbraco.BlockList": [] } + } + """; + + // Parse to JsonObject (simulates what JsonObjectConverter returns) + var jsonObject = JsonNode.Parse(json) as JsonObject; + Assert.That(jsonObject, Is.Not.Null); + + // Verify ToString() returns valid JSON + var toStringResult = jsonObject!.ToString(); + Assert.That(toStringResult, Is.Not.Null.And.Not.Empty); + + // Verify ToJsonString() also works + var toJsonStringResult = jsonObject.ToJsonString(); + Assert.That(toJsonStringResult, Is.Not.Null.And.Not.Empty); + + // Verify both can be parsed back to valid JSON + var reparsedFromToString = JsonDocument.Parse(toStringResult); + Assert.That(reparsedFromToString.RootElement.GetProperty("contentData").GetArrayLength(), Is.EqualTo(2)); + + var reparsedFromToJsonString = JsonDocument.Parse(toJsonStringResult); + Assert.That(reparsedFromToJsonString.RootElement.GetProperty("contentData").GetArrayLength(), Is.EqualTo(2)); + + // Verify both produce equivalent output + Console.WriteLine($"ToString(): {toStringResult}"); + Console.WriteLine($"ToJsonString(): {toJsonStringResult}"); + } + + [Test] + public void ApplyOperation_VerifyOutputCanBeDeserialized() + { + // This test verifies the full flow - apply operation, then verify the resulting + // JSON can be correctly deserialized by downstream systems + var json = """ + { + "values": [ + { + "alias": "contentBlocks", + "culture": null, + "segment": null, + "value": { + "contentData": [ + { "key": "block-1", "values": [{ "alias": "headline", "value": "Block 1" }] }, + { "key": "block-2", "values": [{ "alias": "headline", "value": "Block 2" }] } + ], + "settingsData": [], + "expose": [], + "Layout": { "Umbraco.BlockList": [] } + } + } + ] + } + """; + + // Act - Apply operation + var result = _evaluator.ApplyOperation( + json, + PatchOperationType.Replace, + "$.values[?(@.alias == 'contentBlocks' && @.culture == null && @.segment == null)].value.contentData[?(@.key == 'block-2')].values[?(@.alias == 'headline')].value", + "Updated Block 2"); + + // Verify the result is valid JSON + var doc = JsonDocument.Parse(result); + + // Extract the value property (which would be deserialized as JsonObject) + var valueElement = doc.RootElement.GetProperty("values").EnumerateArray().First().GetProperty("value"); + var valueJson = valueElement.GetRawText(); + + // Parse as JsonObject (simulates JsonObjectConverter behavior) + var valueAsJsonObject = JsonNode.Parse(valueJson) as JsonObject; + Assert.That(valueAsJsonObject, Is.Not.Null); + + // Verify ToString() produces valid JSON that can be parsed by BlockEditorValues + var valueAsString = valueAsJsonObject!.ToString(); + Console.WriteLine($"Value as string: {valueAsString}"); + + // Verify the modified value is in the JSON string + Assert.That(valueAsString, Does.Contain("Updated Block 2")); + + // Parse and verify the structure is maintained + var reparsed = JsonDocument.Parse(valueAsString); + var contentData = reparsed.RootElement.GetProperty("contentData").EnumerateArray().ToList(); + Assert.That(contentData, Has.Count.EqualTo(2)); + + var block2 = contentData.First(b => b.GetProperty("key").GetString() == "block-2"); + var headline = block2.GetProperty("values").EnumerateArray().First().GetProperty("value").GetString(); + Assert.That(headline, Is.EqualTo("Updated Block 2")); + } } From 1bff7a3b54962d65a954fc3dcf1bf57e64293e18 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Sun, 1 Feb 2026 22:26:40 +0100 Subject: [PATCH 08/39] Fix endpoint route collision (Somehow...) --- .../Controllers/Document/PatchDocumentController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs index 7d0e1eaab9b5..e0b40b2eba73 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs @@ -39,7 +39,7 @@ public PatchDocumentController( _documentEditingPresentationFactory = documentEditingPresentationFactory; } - [HttpPatch("{id:guid}")] + [HttpPatch("{id:guid}/patch")] [MapToApiVersion("1.0")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] From 9d2b26f1ffb1b772489bea36e3155bf85395b7f9 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Mon, 2 Mar 2026 10:49:36 +0100 Subject: [PATCH 09/39] Trying a custom way of doing things --- Directory.Packages.props | 2 - src/Umbraco.Cms.Api.Management/CLAUDE.md | 3 +- .../Document/PatchDocumentControllerBase.cs | 2 +- .../JsonBuilderExtensions.cs | 12 +- .../UmbracoBuilderExtensions.cs | 2 +- .../DocumentEditingPresentationFactory.cs | 14 +- .../Patchers/DocumentPatcher.cs | 25 +- .../Services/IJsonPatchService.cs | 9 - .../Services/JsonPatchService.cs | 24 - .../Umbraco.Cms.Api.Management.csproj | 1 - .../Document/PatchOperationRequestModel.cs | 12 +- .../JsonPatch/JsonPatchViewModel.cs | 15 - .../JsonPath/JsonPathCultureExtractor.cs | 130 ++-- .../JsonPath/JsonPathEvaluator.cs | 264 ------- .../PropertyEditors/Patching/PatchEngine.cs | 140 ++++ .../Patching/PatchPathParser.cs | 282 ++++++++ .../Patching/PatchPathResolver.cs | 195 ++++++ .../Patching/PatchPathSegment.cs | 32 + src/Umbraco.Core/Umbraco.Core.csproj | 1 - .../Document/PatchDocumentControllerTests.cs | 40 +- .../Patching/PatchEngineTests.cs | 472 +++++++++++++ .../Patching/PatchPathParserTests.cs | 248 +++++++ .../JsonPath/JsonPathCultureExtractorTests.cs | 115 +-- .../JsonPath/JsonPathEvaluatorTests.cs | 653 ------------------ 24 files changed, 1485 insertions(+), 1208 deletions(-) delete mode 100644 src/Umbraco.Cms.Api.Management/Services/IJsonPatchService.cs delete mode 100644 src/Umbraco.Cms.Api.Management/Services/JsonPatchService.cs delete mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/JsonPatch/JsonPatchViewModel.cs delete mode 100644 src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathEvaluator.cs create mode 100644 src/Umbraco.Core/PropertyEditors/Patching/PatchEngine.cs create mode 100644 src/Umbraco.Core/PropertyEditors/Patching/PatchPathParser.cs create mode 100644 src/Umbraco.Core/PropertyEditors/Patching/PatchPathResolver.cs create mode 100644 src/Umbraco.Core/PropertyEditors/Patching/PatchPathSegment.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchEngineTests.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchPathParserTests.cs delete mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathEvaluatorTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 75f420c40d95..c88e0d51027e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -48,8 +48,6 @@ - - diff --git a/src/Umbraco.Cms.Api.Management/CLAUDE.md b/src/Umbraco.Cms.Api.Management/CLAUDE.md index f9908cc6f65c..c84fc409df55 100644 --- a/src/Umbraco.Cms.Api.Management/CLAUDE.md +++ b/src/Umbraco.Cms.Api.Management/CLAUDE.md @@ -26,7 +26,7 @@ RESTful API for Umbraco backoffice operations. Manages content, media, users, an - **Validation**: FluentValidation via base controllers - **Serialization**: System.Text.Json with custom converters - **Mapping**: Manual presentation factories (no AutoMapper) -- **Patching**: JsonPatch.Net for PATCH operations +- **Patching**: Custom patch engine for PATCH operations (Umbraco.Core.PropertyEditors.Patching) - **Real-time**: SignalR hubs (`BackofficeHub`, `ServerEventHub`) - **DI**: Microsoft.Extensions.DependencyInjection via `ManagementApiComposer` @@ -70,7 +70,6 @@ src/Umbraco.Cms.Api.Management/ - **Umbraco.Cms.Api.Common** - Shared API infrastructure (base controllers, OpenAPI config) - **Umbraco.Infrastructure** - Service implementations, data access - **Umbraco.PublishedCache.HybridCache** - Published content queries -- **JsonPatch.Net** - JSON Patch (RFC 6902) support - **Swashbuckle.AspNetCore** - OpenAPI generation ### Design Patterns diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs index 19b13ad3e318..9da9eacf6084 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs @@ -44,7 +44,7 @@ protected IActionResult ContentPatchingOperationStatusResult(ContentPatchingOper { ContentPatchingOperationStatus.InvalidOperation => BadRequest(problemDetailsBuilder .WithTitle("Invalid operation") - .WithDetail("One or more PATCH operations were invalid. Check operation structure, JSONPath syntax, and operation types.") + .WithDetail("One or more PATCH operations were invalid. Check operation structure, path syntax, and operation types.") .Build()), ContentPatchingOperationStatus.InvalidCulture => BadRequest(problemDetailsBuilder .WithTitle("Invalid culture") diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs index 3322b289780f..069d4c5f974d 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs @@ -1,16 +1,8 @@ -using Microsoft.Extensions.DependencyInjection; -using Umbraco.Cms.Core.DependencyInjection; -using Umbraco.Cms.Api.Management.Services; +using Umbraco.Cms.Core.DependencyInjection; namespace Umbraco.Cms.Api.Management.DependencyInjection; public static class JsonBuilderExtensions { - internal static IUmbracoBuilder AddJson(this IUmbracoBuilder builder) - { - builder.Services - .AddTransient(); - - return builder; - } + internal static IUmbracoBuilder AddJson(this IUmbracoBuilder builder) => builder; } diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs index a99e6de55303..05409fdff043 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs @@ -24,7 +24,7 @@ public static IUmbracoBuilder AddUmbracoManagementApi(this IUmbracoBuilder build builder.Services.AddUnique(); builder.AddUmbracoApiOpenApiUI(); - if (!services.Any(x => !x.IsKeyedService && x.ImplementationType == typeof(JsonPatchService))) + if (!services.Any(x => !x.IsKeyedService && x.ImplementationType == typeof(ConfigureUmbracoBackofficeJsonOptions))) { ModelsBuilderBuilderExtensions.AddModelsBuilder(builder) .AddJson() diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs index 35cfd7c94f60..5c2172a7829b 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs @@ -3,6 +3,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PropertyEditors.Patching; using Umbraco.Cms.Core.Services; using Umbraco.Extensions; @@ -106,7 +107,6 @@ private DocumentVariantRequestModel[] MapVariantsToRequestModel(IContent content public ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel) { - var cultureExtractor = new Umbraco.Cms.Core.PropertyEditors.JsonPath.JsonPathCultureExtractor(); var operations = requestModel.Operations.Select(op => new PatchOperationModel { Op = MapOperationType(op.Op), @@ -114,9 +114,15 @@ public ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel) Value = op.Value }).ToArray(); - var affectedCultures = cultureExtractor.ExtractCulturesFromOperations(operations.Select(o => o.Path)).ToArray(); - var affectedSegments = operations - .SelectMany(o => cultureExtractor.ExtractSegments(o.Path)) + var paths = operations.Select(o => o.Path).ToArray(); + + var affectedCultures = paths + .SelectMany(PatchPathParser.ExtractCultures) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var affectedSegments = paths + .SelectMany(PatchPathParser.ExtractSegments) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); diff --git a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs index 39d68c686e87..78a3e3febf9e 100644 --- a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs +++ b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs @@ -4,24 +4,20 @@ using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; -using Umbraco.Cms.Core.PropertyEditors.JsonPath; +using Umbraco.Cms.Core.PropertyEditors.Patching; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; namespace Umbraco.Cms.Api.Management.Patchers; /// -/// Applies JSON Patch operations with JSONPath to documents, converting them to update models. +/// Applies patch operations with Umbraco's custom path syntax to documents, converting them to update models. /// public class DocumentPatcher { private readonly IContentEditingService _contentEditingService; - private readonly IContentTypeService _contentTypeService; - private readonly ILanguageService _languageService; private readonly IJsonSerializer _jsonSerializer; private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; - private readonly JsonPathEvaluator _jsonPathEvaluator; - private readonly JsonPathCultureExtractor _cultureExtractor; public DocumentPatcher( IContentEditingService contentEditingService, @@ -31,12 +27,8 @@ public DocumentPatcher( IDocumentEditingPresentationFactory documentEditingPresentationFactory) { _contentEditingService = contentEditingService; - _contentTypeService = contentTypeService; - _languageService = languageService; _jsonSerializer = jsonSerializer; _documentEditingPresentationFactory = documentEditingPresentationFactory; - _jsonPathEvaluator = new JsonPathEvaluator(); - _cultureExtractor = new JsonPathCultureExtractor(); } /// @@ -55,7 +47,7 @@ public async Task _jsonSerializer = jsonSerializer; - - public PatchResult? Patch(JsonPatchViewModel[] patchViewModel, object objectToPatch) - { - var patchString = _jsonSerializer.Serialize(patchViewModel); - - var docString = _jsonSerializer.Serialize(objectToPatch); - JsonPatch? patch = _jsonSerializer.Deserialize(patchString); - var doc = JsonNode.Parse(docString); - return patch?.Apply(doc); - } -} diff --git a/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj b/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj index 14676c2e49a5..fa39a7881c4a 100644 --- a/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj +++ b/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj @@ -23,7 +23,6 @@ - diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchOperationRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchOperationRequestModel.cs index 0a49fb06b713..52d5514e05f3 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchOperationRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchOperationRequestModel.cs @@ -3,7 +3,7 @@ namespace Umbraco.Cms.Api.Management.ViewModels.Document; /// -/// Represents a single PATCH operation following JSON Patch (RFC 6902) semantics with JSONPath. +/// Represents a single PATCH operation using Umbraco's path syntax. /// public class PatchOperationRequestModel { @@ -14,7 +14,15 @@ public class PatchOperationRequestModel public string Op { get; set; } = string.Empty; /// - /// JSONPath expression identifying the target location (e.g., "$.values[?(@.alias == 'title' && @.culture == 'en-US')].value"). + /// Path expression identifying the target location using Umbraco's extended JSON Pointer syntax. + /// + /// Examples: + /// + /// /variants[culture=en-US,segment=null]/name + /// /values[alias=title,culture=en-US,segment=null]/value + /// /values[alias=blocks,culture=null,segment=null]/value/contentData/- (append to array) + /// + /// /// [Required] public string Path { get; set; } = string.Empty; diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/JsonPatch/JsonPatchViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/JsonPatch/JsonPatchViewModel.cs deleted file mode 100644 index 83861cf99082..000000000000 --- a/src/Umbraco.Cms.Api.Management/ViewModels/JsonPatch/JsonPatchViewModel.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Umbraco.Cms.Api.Management.ViewModels.JsonPatch; - -public class JsonPatchViewModel -{ - [Required] - public string Op { get; set; } = string.Empty; - - [Required] - public string Path { get; set; } = string.Empty; - - [Required] - public object Value { get; set; } = null!; -} diff --git a/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathCultureExtractor.cs b/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathCultureExtractor.cs index ec5ff730a316..cda50531e870 100644 --- a/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathCultureExtractor.cs +++ b/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathCultureExtractor.cs @@ -1,125 +1,80 @@ -using System.Text.RegularExpressions; +using Umbraco.Cms.Core.PropertyEditors.Patching; namespace Umbraco.Cms.Core.PropertyEditors.JsonPath; /// -/// Extracts culture and segment information from JSONPath expressions for authorization purposes. -/// Parses filter expressions like "@.culture == 'en-US'" to extract the culture value. +/// Extracts culture and segment information from patch path expressions for authorization purposes. +/// Delegates to for path parsing. /// public class JsonPathCultureExtractor { - // Regex patterns for extracting culture and segment from JSONPath filter expressions - // Matches: @.culture == 'value' or @.culture == "value" - private static readonly Regex CulturePattern = new(@"@\.culture\s*==\s*['""]([^'""]+)['""]", RegexOptions.Compiled); - - // Matches: @.segment == 'value' or @.segment == "value" - private static readonly Regex SegmentPattern = new(@"@\.segment\s*==\s*['""]([^'""]+)['""]", RegexOptions.Compiled); - - // Matches: @.culture == null - private static readonly Regex CultureNullPattern = new(@"@\.culture\s*==\s*null\b", RegexOptions.Compiled); - - // Matches: @.segment == null - private static readonly Regex SegmentNullPattern = new(@"@\.segment\s*==\s*null\b", RegexOptions.Compiled); - /// - /// Extracts all unique cultures referenced in a JSONPath expression. + /// Extracts all unique cultures referenced in a path expression. /// - /// The JSONPath expression to parse. + /// The patch path expression to parse. /// A set of culture codes (e.g., "en-US", "da-DK"), or empty set if no cultures found. public ISet ExtractCultures(string pathExpression) - { - if (string.IsNullOrWhiteSpace(pathExpression)) - { - return new HashSet(); - } - - var cultures = new HashSet(StringComparer.OrdinalIgnoreCase); - - var matches = CulturePattern.Matches(pathExpression); - foreach (Match match in matches) - { - if (match.Success && match.Groups.Count > 1) - { - cultures.Add(match.Groups[1].Value); - } - } - - return cultures; - } + => PatchPathParser.ExtractCultures(pathExpression); /// - /// Extracts all unique segments referenced in a JSONPath expression. + /// Extracts all unique segments referenced in a path expression. /// - /// The JSONPath expression to parse. + /// The patch path expression to parse. /// A set of segment names, or empty set if no segments found. public ISet ExtractSegments(string pathExpression) - { - if (string.IsNullOrWhiteSpace(pathExpression)) - { - return new HashSet(); - } - - var segments = new HashSet(StringComparer.OrdinalIgnoreCase); - - var matches = SegmentPattern.Matches(pathExpression); - foreach (Match match in matches) - { - if (match.Success && match.Groups.Count > 1) - { - segments.Add(match.Groups[1].Value); - } - } - - return segments; - } + => PatchPathParser.ExtractSegments(pathExpression); /// - /// Checks if a JSONPath expression explicitly filters for null culture (invariant). + /// Checks if a path expression explicitly targets invariant content (culture=null). /// - /// The JSONPath expression to parse. - /// True if the expression contains "@.culture == null", false otherwise. + /// The path expression to parse. + /// True if the expression targets invariant culture, false otherwise. public bool ContainsInvariantCultureFilter(string pathExpression) - { - if (string.IsNullOrWhiteSpace(pathExpression)) - { - return false; - } - - return CultureNullPattern.IsMatch(pathExpression); - } + => PatchPathParser.TargetsInvariantCulture(pathExpression); /// - /// Checks if a JSONPath expression explicitly filters for null segment. + /// Checks if a path expression explicitly targets null segment. /// - /// The JSONPath expression to parse. - /// True if the expression contains "@.segment == null", false otherwise. + /// The path expression to parse. + /// True if the expression targets null segment, false otherwise. public bool ContainsNullSegmentFilter(string pathExpression) { - if (string.IsNullOrWhiteSpace(pathExpression)) + if (!PatchPathParser.IsValid(pathExpression)) { return false; } - return SegmentNullPattern.IsMatch(pathExpression); + PatchPathSegment[] segments = PatchPathParser.Parse(pathExpression); + foreach (PatchPathSegment segment in segments) + { + if (segment is FilterSegment filter) + { + foreach (FilterCondition condition in filter.Conditions) + { + if (string.Equals(condition.Key, "segment", StringComparison.OrdinalIgnoreCase) + && condition.Value is null) + { + return true; + } + } + } + } + + return false; } /// - /// Extracts all cultures from a collection of JSONPath expressions. + /// Extracts all cultures from a collection of path expressions. /// - /// Collection of JSONPath expressions. + /// Collection of path expressions. /// A set of all unique cultures across all expressions. public ISet ExtractCulturesFromOperations(IEnumerable pathExpressions) { - if (pathExpressions == null) - { - return new HashSet(); - } - var allCultures = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var path in pathExpressions) { - var cultures = ExtractCultures(path); + ISet cultures = ExtractCultures(path); foreach (var culture in cultures) { allCultures.Add(culture); @@ -132,15 +87,8 @@ public ISet ExtractCulturesFromOperations(IEnumerable pathExpres /// /// Checks if any of the path expressions target invariant content (null culture). /// - /// Collection of JSONPath expressions. + /// Collection of path expressions. /// True if any expression contains invariant culture filter. public bool AnyOperationTargetsInvariantCulture(IEnumerable pathExpressions) - { - if (pathExpressions == null) - { - return false; - } - - return pathExpressions.Any(ContainsInvariantCultureFilter); - } + => pathExpressions.Any(ContainsInvariantCultureFilter); } diff --git a/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathEvaluator.cs b/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathEvaluator.cs deleted file mode 100644 index cc4aec7299b8..000000000000 --- a/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathEvaluator.cs +++ /dev/null @@ -1,264 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using JsonCons.JsonPath; -using Umbraco.Cms.Core.Models.ContentEditing; - -namespace Umbraco.Cms.Core.PropertyEditors.JsonPath; - -/// -/// Evaluates JSONPath expressions against JSON documents. -/// Wraps JsonCons.JsonPath library to provide RFC 9535 compliant JSONPath evaluation. -/// -public class JsonPathEvaluator -{ - /// - /// Evaluates a JSONPath expression against a JSON document and returns matching nodes. - /// - /// The JSON document to query. - /// The JSONPath expression (e.g., "$.values[?(@.alias == 'title')]"). - /// A list of matching JSON elements, or an empty list if no matches found. - /// Thrown when the path expression is invalid. - public IReadOnlyList Select(JsonDocument jsonDocument, string pathExpression) - { - if (jsonDocument == null) - { - throw new ArgumentNullException(nameof(jsonDocument)); - } - - if (string.IsNullOrWhiteSpace(pathExpression)) - { - throw new ArgumentException("Path expression cannot be null or empty.", nameof(pathExpression)); - } - - try - { - var selector = JsonSelector.Parse(pathExpression); - var results = selector.Select(jsonDocument.RootElement); - return results.ToList(); - } - catch (JsonException ex) - { - throw new ArgumentException($"Invalid JSONPath expression: {pathExpression}", nameof(pathExpression), ex); - } - } - - /// - /// Evaluates a JSONPath expression and returns the first matching node, or null if no match found. - /// - /// The JSON document to query. - /// The JSONPath expression. - /// The first matching JSON element, or null if no matches found. - public JsonElement? SelectSingle(JsonDocument jsonDocument, string pathExpression) - { - var results = Select(jsonDocument, pathExpression); - return results.Count > 0 ? results[0] : null; - } - - /// - /// Checks if a JSONPath expression matches any nodes in the document. - /// - /// The JSON document to query. - /// The JSONPath expression. - /// True if at least one node matches, false otherwise. - public bool Exists(JsonDocument jsonDocument, string pathExpression) - { - var results = Select(jsonDocument, pathExpression); - return results.Count > 0; - } - - /// - /// Validates that a JSONPath expression is syntactically correct. - /// - /// The JSONPath expression to validate. - /// True if the expression is valid, false otherwise. - public bool IsValidExpression(string pathExpression) - { - if (string.IsNullOrWhiteSpace(pathExpression)) - { - return false; - } - - try - { - JsonSelector.Parse(pathExpression); - return true; - } - catch - { - return false; - } - } - - /// - /// Applies a PATCH operation (replace/add/remove) to a JSON string at the specified JSONPath. - /// Returns a new JSON string with the modification applied. - /// - /// The JSON string to modify. - /// The operation type (Replace, Add, Remove). - /// The JSONPath expression identifying the target location. - /// The value to set (required for Replace and Add operations). - /// The modified JSON string. - /// Thrown when the path matches no elements for Replace/Remove operations. - public string ApplyOperation(string jsonString, PatchOperationType op, string path, object? value) - { - if (string.IsNullOrWhiteSpace(jsonString)) - { - throw new ArgumentNullException(nameof(jsonString)); - } - - if (string.IsNullOrWhiteSpace(path)) - { - throw new ArgumentException("Path expression cannot be null or empty.", nameof(path)); - } - - // Parse to mutable JsonNode - JsonNode? rootNode = JsonNode.Parse(jsonString); - if (rootNode == null) - { - throw new InvalidOperationException("Failed to parse JSON string."); - } - - // Use JsonCons to find matching nodes (path-value pairs) - using var doc = JsonDocument.Parse(jsonString); - var selector = JsonSelector.Parse(path); - var nodes = selector.SelectNodes(doc.RootElement); - var nodeList = nodes.ToList(); - - if (nodeList.Count == 0 && op != PatchOperationType.Add) - { - throw new InvalidOperationException($"JSONPath expression '{path}' matched no elements."); - } - - // Convert value to JsonNode - JsonNode? valueNode = value != null - ? JsonSerializer.SerializeToNode(value) - : null; - - // Apply operation to each matched path - foreach (var node in nodeList) - { - var jsonPointer = node.Path.ToJsonPointer(); - ApplyOperationAtJsonPointer(rootNode, jsonPointer, op, valueNode); - } - - return rootNode.ToJsonString(); - } - - private void ApplyOperationAtJsonPointer(JsonNode rootNode, string jsonPointer, PatchOperationType op, JsonNode? valueNode) - { - // Parse JSON Pointer into segments - var segments = ParseJsonPointer(jsonPointer); - - if (segments.Length == 0) - { - throw new InvalidOperationException("Cannot modify root node directly."); - } - - // Navigate to the parent of the target node - JsonNode? current = rootNode; - JsonNode? parent = null; - object? lastKey = null; // string for object property, int for array index - - for (int i = 0; i < segments.Length; i++) - { - var segment = segments[i]; - parent = current; - - // Try to parse as array index - if (int.TryParse(segment, out int arrayIndex) && current is JsonArray) - { - lastKey = arrayIndex; - current = current?[arrayIndex]; - } - else - { - // Treat as object property name - lastKey = segment; - current = current?[segment]; - } - } - - if (parent == null || lastKey == null) - { - throw new InvalidOperationException("Cannot modify root node directly."); - } - - // Apply the operation - switch (op) - { - case PatchOperationType.Replace: - if (lastKey is string propertyName) - { - if (parent is JsonObject parentObj) - { - parentObj[propertyName] = valueNode?.DeepClone(); - } - } - else if (lastKey is int index) - { - if (parent is JsonArray parentArr) - { - parentArr[index] = valueNode?.DeepClone(); - } - } - break; - - case PatchOperationType.Add: - if (lastKey is string addPropertyName) - { - if (parent is JsonObject addParentObj) - { - addParentObj[addPropertyName] = valueNode?.DeepClone(); - } - } - else if (lastKey is int addIndex) - { - if (parent is JsonArray addParentArr) - { - addParentArr.Insert(addIndex, valueNode?.DeepClone()); - } - } - break; - - case PatchOperationType.Remove: - if (lastKey is string removePropertyName) - { - if (parent is JsonObject removeParentObj) - { - removeParentObj.Remove(removePropertyName); - } - } - else if (lastKey is int removeIndex) - { - if (parent is JsonArray removeParentArr) - { - removeParentArr.RemoveAt(removeIndex); - } - } - break; - } - } - - /// - /// Parses a JSON Pointer string (RFC 6901) into path segments. - /// - private static string[] ParseJsonPointer(string jsonPointer) - { - if (string.IsNullOrEmpty(jsonPointer) || jsonPointer == "/") - { - return Array.Empty(); - } - - // Remove leading slash and split - var pointer = jsonPointer.StartsWith("/") ? jsonPointer.Substring(1) : jsonPointer; - var segments = pointer.Split('/'); - - // Unescape JSON Pointer special characters (~1 -> /, ~0 -> ~) - for (int i = 0; i < segments.Length; i++) - { - segments[i] = segments[i].Replace("~1", "/").Replace("~0", "~"); - } - - return segments; - } -} diff --git a/src/Umbraco.Core/PropertyEditors/Patching/PatchEngine.cs b/src/Umbraco.Core/PropertyEditors/Patching/PatchEngine.cs new file mode 100644 index 000000000000..fb7125bac06b --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/Patching/PatchEngine.cs @@ -0,0 +1,140 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Core.PropertyEditors.Patching; + +/// +/// Applies patch operations to JSON documents using Umbraco's custom path syntax. +/// +public static class PatchEngine +{ + /// + /// Applies a single patch operation to a JSON string and returns the modified JSON. + /// + /// The JSON string to modify. + /// The operation type (Replace, Add, Remove). + /// The patch path expression. + /// The value to set (required for Replace and Add operations). + /// The modified JSON string. + /// Thrown when the operation cannot be applied. + /// Thrown when the path syntax is invalid. + public static string ApplyOperation(string json, PatchOperationType op, string path, object? value) + { + if (string.IsNullOrWhiteSpace(json)) + { + throw new ArgumentNullException(nameof(json)); + } + + PatchPathSegment[] segments = PatchPathParser.Parse(path); + + JsonNode? rootNode = JsonNode.Parse(json); + if (rootNode is null) + { + throw new InvalidOperationException("Failed to parse JSON string."); + } + + JsonNode? valueNode = value is not null + ? JsonSerializer.SerializeToNode(value) + : null; + + ResolvedTarget target = PatchPathResolver.Resolve(rootNode, segments); + ApplyMutation(target, op, valueNode); + + return rootNode.ToJsonString(); + } + + private static void ApplyMutation(ResolvedTarget target, PatchOperationType op, JsonNode? valueNode) + { + switch (op) + { + case PatchOperationType.Replace: + ApplyReplace(target, valueNode); + break; + + case PatchOperationType.Add: + ApplyAdd(target, valueNode); + break; + + case PatchOperationType.Remove: + ApplyRemove(target); + break; + + default: + throw new InvalidOperationException($"Unsupported operation type: {op}"); + } + } + + private static void ApplyReplace(ResolvedTarget target, JsonNode? valueNode) + { + if (target.IsAppend) + { + throw new InvalidOperationException("Cannot use 'replace' with append target '/-'."); + } + + switch (target.Key) + { + case string propertyName when target.Parent is JsonObject parentObj: + parentObj[propertyName] = valueNode?.DeepClone(); + break; + + case int index when target.Parent is JsonArray parentArr: + parentArr[index] = valueNode?.DeepClone(); + break; + + default: + throw new InvalidOperationException("Cannot replace at the resolved target location."); + } + } + + private static void ApplyAdd(ResolvedTarget target, JsonNode? valueNode) + { + if (target.IsAppend) + { + // Append to end of array + if (target.Parent is JsonArray appendArray) + { + appendArray.Add(valueNode?.DeepClone()); + return; + } + + throw new InvalidOperationException("Append target '/-' requires parent to be an array."); + } + + switch (target.Key) + { + case string propertyName when target.Parent is JsonObject parentObj: + parentObj[propertyName] = valueNode?.DeepClone(); + break; + + case int index when target.Parent is JsonArray parentArr: + parentArr.Insert(index, valueNode?.DeepClone()); + break; + + default: + throw new InvalidOperationException("Cannot add at the resolved target location."); + } + } + + private static void ApplyRemove(ResolvedTarget target) + { + if (target.IsAppend) + { + throw new InvalidOperationException("Cannot use 'remove' with append target '/-'."); + } + + switch (target.Key) + { + case string propertyName when target.Parent is JsonObject parentObj: + parentObj.Remove(propertyName); + break; + + case int index when target.Parent is JsonArray parentArr: + parentArr.RemoveAt(index); + break; + + default: + throw new InvalidOperationException("Cannot remove at the resolved target location."); + } + } +} diff --git a/src/Umbraco.Core/PropertyEditors/Patching/PatchPathParser.cs b/src/Umbraco.Core/PropertyEditors/Patching/PatchPathParser.cs new file mode 100644 index 000000000000..fe0fe3c5682a --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/Patching/PatchPathParser.cs @@ -0,0 +1,282 @@ +namespace Umbraco.Cms.Core.PropertyEditors.Patching; + +/// +/// Parses Umbraco's custom patch path syntax into typed segments. +/// +/// Syntax is based on JSON Pointer (RFC 6901) extended with array filter expressions: +/// +/// /property — access object property +/// [key=value,key2=null] — filter array element by matching properties +/// /0 — access array element by index +/// /- — append to end of array (Add operations only) +/// +/// +/// +/// /variants[culture=en-US,segment=null]/name +/// /values[alias=title,culture=en-US,segment=null]/value +/// +/// +public static class PatchPathParser +{ + /// + /// Parses a patch path string into an array of typed segments. + /// + /// The patch path expression. + /// An array of representing the parsed path. + /// Thrown when the path syntax is invalid. + public static PatchPathSegment[] Parse(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + throw new FormatException("Path cannot be null or empty."); + } + + if (!path.StartsWith('/')) + { + throw new FormatException("Path must start with '/'."); + } + + var segments = new List(); + var span = path.AsSpan(1); // Skip leading '/' + + while (span.Length > 0) + { + // Find the next '/' or '[' to determine segment boundary + var slashIndex = span.IndexOf('/'); + var bracketIndex = span.IndexOf('['); + + ReadOnlySpan token; + if (slashIndex < 0 && bracketIndex < 0) + { + // Last segment, no more '/' or '[' + token = span; + span = ReadOnlySpan.Empty; + } + else if (bracketIndex >= 0 && (slashIndex < 0 || bracketIndex < slashIndex)) + { + // '[' comes before '/' — property + filter + token = span[..bracketIndex]; + span = span[bracketIndex..]; + } + else + { + // '/' comes first — regular segment + token = span[..slashIndex]; + span = span[(slashIndex + 1)..]; + } + + // Parse the property/index token + if (token.Length > 0) + { + segments.Add(ParseToken(token)); + } + + // Parse filter if present + if (span.Length > 0 && span[0] == '[') + { + var closeBracket = span.IndexOf(']'); + if (closeBracket < 0) + { + throw new FormatException("Unclosed filter bracket '[' in path."); + } + + ReadOnlySpan filterContent = span[1..closeBracket]; + segments.Add(ParseFilter(filterContent)); + + span = span[(closeBracket + 1)..]; + + // Skip the '/' after the filter if present + if (span.Length > 0 && span[0] == '/') + { + span = span[1..]; + } + } + } + + if (segments.Count == 0) + { + throw new FormatException("Path must contain at least one segment."); + } + + return segments.ToArray(); + } + + /// + /// Validates that a path string is syntactically correct. + /// + /// The path expression to validate. + /// True if the path is valid, false otherwise. + public static bool IsValid(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + try + { + Parse(path); + return true; + } + catch (FormatException) + { + return false; + } + } + + /// + /// Extracts all culture values from filter segments in a path. + /// + public static ISet ExtractCultures(string path) + { + var cultures = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (!IsValid(path)) + { + return cultures; + } + + PatchPathSegment[] segments = Parse(path); + foreach (PatchPathSegment segment in segments) + { + if (segment is FilterSegment filter) + { + foreach (FilterCondition condition in filter.Conditions) + { + if (string.Equals(condition.Key, "culture", StringComparison.OrdinalIgnoreCase) + && condition.Value is not null) + { + cultures.Add(condition.Value); + } + } + } + } + + return cultures; + } + + /// + /// Extracts all segment values from filter segments in a path. + /// + public static ISet ExtractSegments(string path) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (!IsValid(path)) + { + return result; + } + + PatchPathSegment[] segments = Parse(path); + foreach (PatchPathSegment segment in segments) + { + if (segment is FilterSegment filter) + { + foreach (FilterCondition condition in filter.Conditions) + { + if (string.Equals(condition.Key, "segment", StringComparison.OrdinalIgnoreCase) + && condition.Value is not null) + { + result.Add(condition.Value); + } + } + } + } + + return result; + } + + /// + /// Checks if a path targets invariant content (culture=null filter). + /// + public static bool TargetsInvariantCulture(string path) + { + if (!IsValid(path)) + { + return false; + } + + PatchPathSegment[] segments = Parse(path); + foreach (PatchPathSegment segment in segments) + { + if (segment is FilterSegment filter) + { + foreach (FilterCondition condition in filter.Conditions) + { + if (string.Equals(condition.Key, "culture", StringComparison.OrdinalIgnoreCase) + && condition.Value is null) + { + return true; + } + } + } + } + + return false; + } + + private static PatchPathSegment ParseToken(ReadOnlySpan token) + { + if (token.Length == 1 && token[0] == '-') + { + return new AppendSegment(); + } + + if (int.TryParse(token, out var index)) + { + return new IndexSegment(index); + } + + return new PropertySegment(token.ToString()); + } + + private static FilterSegment ParseFilter(ReadOnlySpan filterContent) + { + if (filterContent.IsEmpty) + { + throw new FormatException("Empty filter expression."); + } + + var conditions = new List(); + + while (filterContent.Length > 0) + { + // Find comma separator (but not inside nested brackets) + var commaIndex = filterContent.IndexOf(','); + ReadOnlySpan pair; + + if (commaIndex < 0) + { + pair = filterContent; + filterContent = ReadOnlySpan.Empty; + } + else + { + pair = filterContent[..commaIndex]; + filterContent = filterContent[(commaIndex + 1)..]; + } + + var equalsIndex = pair.IndexOf('='); + if (equalsIndex < 0) + { + throw new FormatException($"Filter condition must contain '=': '{pair.ToString()}'"); + } + + var key = pair[..equalsIndex].Trim().ToString(); + var rawValue = pair[(equalsIndex + 1)..].Trim(); + + if (key.Length == 0) + { + throw new FormatException("Filter condition key cannot be empty."); + } + + string? value = rawValue.Equals("null", StringComparison.OrdinalIgnoreCase) + ? null + : rawValue.ToString(); + + conditions.Add(new FilterCondition(key, value)); + } + + return new FilterSegment(conditions.ToArray()); + } +} diff --git a/src/Umbraco.Core/PropertyEditors/Patching/PatchPathResolver.cs b/src/Umbraco.Core/PropertyEditors/Patching/PatchPathResolver.cs new file mode 100644 index 000000000000..75da61574fc3 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/Patching/PatchPathResolver.cs @@ -0,0 +1,195 @@ +using System.Text.Json.Nodes; + +namespace Umbraco.Cms.Core.PropertyEditors.Patching; + +/// +/// The result of resolving a patch path against a JSON document. +/// +public sealed class ResolvedTarget +{ + /// + /// The parent node of the target location. + /// + public required JsonNode Parent { get; init; } + + /// + /// The key identifying the target within the parent: + /// a for object properties, an for array indices, + /// or null for append operations. + /// + public required object? Key { get; init; } + + /// + /// The current value at the target location, or null if the location doesn't exist yet (e.g., for Add). + /// + public JsonNode? Current { get; init; } + + /// + /// Whether this target represents an append to the end of an array. + /// + public bool IsAppend { get; init; } +} + +/// +/// Resolves parsed patch path segments against a JSON document to find the target node. +/// +public static class PatchPathResolver +{ + /// + /// Resolves a parsed path against a JSON node tree and returns the target location. + /// + /// The root JSON node. + /// The parsed path segments. + /// The resolved target containing the parent node and key. + /// Thrown when the path cannot be resolved. + public static ResolvedTarget Resolve(JsonNode root, PatchPathSegment[] segments) + { + if (segments.Length == 0) + { + throw new InvalidOperationException("Path must contain at least one segment."); + } + + JsonNode? current = root; + JsonNode? parent = null; + object? lastKey = null; + + for (var i = 0; i < segments.Length; i++) + { + PatchPathSegment segment = segments[i]; + + switch (segment) + { + case PropertySegment property: + if (current is not JsonObject obj) + { + throw new InvalidOperationException( + $"Expected object at path segment '{property.Name}', but found {current?.GetType().Name ?? "null"}."); + } + + parent = current; + lastKey = property.Name; + current = obj[property.Name]; + break; + + case FilterSegment filter: + if (current is not JsonArray filterArray) + { + throw new InvalidOperationException( + $"Expected array for filter operation, but found {current?.GetType().Name ?? "null"}."); + } + + var (matchedNode, matchedIndex) = FindMatchingElement(filterArray, filter.Conditions); + if (matchedNode is null) + { + throw new InvalidOperationException( + $"No array element matched filter conditions: [{FormatConditions(filter.Conditions)}]."); + } + + parent = filterArray; + lastKey = matchedIndex; + current = matchedNode; + break; + + case IndexSegment index: + if (current is not JsonArray indexArray) + { + throw new InvalidOperationException( + $"Expected array for index access, but found {current?.GetType().Name ?? "null"}."); + } + + if (index.Index < 0 || index.Index >= indexArray.Count) + { + throw new InvalidOperationException( + $"Array index {index.Index} is out of bounds (array length: {indexArray.Count})."); + } + + parent = indexArray; + lastKey = index.Index; + current = indexArray[index.Index]; + break; + + case AppendSegment: + if (i != segments.Length - 1) + { + throw new InvalidOperationException("Append segment '-' must be the last segment in the path."); + } + + if (current is not JsonArray) + { + throw new InvalidOperationException( + $"Expected array for append operation, but found {current?.GetType().Name ?? "null"}."); + } + + return new ResolvedTarget + { + Parent = current, + Key = null, + Current = null, + IsAppend = true, + }; + } + } + + if (parent is null || lastKey is null) + { + throw new InvalidOperationException("Could not resolve path to a valid target."); + } + + return new ResolvedTarget + { + Parent = parent, + Key = lastKey, + Current = current, + }; + } + + private static (JsonNode? Node, int Index) FindMatchingElement(JsonArray array, FilterCondition[] conditions) + { + for (var i = 0; i < array.Count; i++) + { + JsonNode? element = array[i]; + if (element is JsonObject elementObj && MatchesAllConditions(elementObj, conditions)) + { + return (element, i); + } + } + + return (null, -1); + } + + private static bool MatchesAllConditions(JsonObject element, FilterCondition[] conditions) + { + foreach (FilterCondition condition in conditions) + { + JsonNode? propertyNode = element[condition.Key]; + + if (condition.Value is null) + { + // Condition expects null: property must be missing or explicitly null + if (propertyNode is not null) + { + return false; + } + } + else + { + // Condition expects a specific value: compare as string + if (propertyNode is null) + { + return false; + } + + var nodeValue = propertyNode.GetValue(); + if (!string.Equals(nodeValue, condition.Value, StringComparison.Ordinal)) + { + return false; + } + } + } + + return true; + } + + private static string FormatConditions(FilterCondition[] conditions) => + string.Join(", ", conditions.Select(c => $"{c.Key}={c.Value ?? "null"}")); +} diff --git a/src/Umbraco.Core/PropertyEditors/Patching/PatchPathSegment.cs b/src/Umbraco.Core/PropertyEditors/Patching/PatchPathSegment.cs new file mode 100644 index 000000000000..c2e73584af5b --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/Patching/PatchPathSegment.cs @@ -0,0 +1,32 @@ +namespace Umbraco.Cms.Core.PropertyEditors.Patching; + +/// +/// Represents a single segment in a patch path expression. +/// +public abstract record PatchPathSegment; + +/// +/// Accesses a named property on an object (e.g., /name, /variants). +/// +public sealed record PropertySegment(string Name) : PatchPathSegment; + +/// +/// Filters an array by matching element properties (e.g., [culture=en-US,segment=null]). +/// All conditions must match (AND logic). +/// +public sealed record FilterSegment(FilterCondition[] Conditions) : PatchPathSegment; + +/// +/// Accesses an array element by numeric index (e.g., /0, /1). +/// +public sealed record IndexSegment(int Index) : PatchPathSegment; + +/// +/// Marks the end-of-array position for Add operations (/-). +/// +public sealed record AppendSegment : PatchPathSegment; + +/// +/// A single filter condition: key=value where value can be null. +/// +public sealed record FilterCondition(string Key, string? Value); diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 9eaf69097975..12fa70846f37 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -35,7 +35,6 @@ - diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs index bf304d09ef7c..9089b8f4617f 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs @@ -110,7 +110,7 @@ protected override async Task ClientRequest() new PatchOperationRequestModel { Op = "replace", - Path = "$.variants[?(@.culture == null && @.segment == null)].name", + Path = "/variants[culture=null,segment=null]/name", Value = "Updated Name" } } @@ -171,7 +171,7 @@ public async Task PatchDocument_SingleCulture_UpdatesOnlyThatCulture() new PatchOperationRequestModel { Op = "replace", - Path = "$.variants[?(@.culture == 'en-US' && @.segment == null)].name", + Path = "/variants[culture=en-US,segment=null]/name", Value = "Updated English Name" } } @@ -248,13 +248,13 @@ public async Task PatchDocument_MultipleCultures_UpdatesBoth() new PatchOperationRequestModel { Op = "replace", - Path = "$.variants[?(@.culture == 'en-US' && @.segment == null)].name", + Path = "/variants[culture=en-US,segment=null]/name", Value = "Updated English" }, new PatchOperationRequestModel { Op = "replace", - Path = "$.variants[?(@.culture == 'da-DK' && @.segment == null)].name", + Path = "/variants[culture=da-DK,segment=null]/name", Value = "Updated Danish" } } @@ -320,7 +320,7 @@ public async Task PatchDocument_NonExistentCulture_ReturnsInvalidCulture() new PatchOperationRequestModel { Op = "replace", - Path = "$.variants[?(@.culture == 'fr-FR' && @.segment == null)].name", + Path = "/variants[culture=fr-FR,segment=null]/name", Value = "French Name" // fr-FR not enabled } } @@ -384,7 +384,7 @@ public async Task PatchDocument_PropertyValue_UpdatesProperty() new PatchOperationRequestModel { Op = "replace", - Path = "$.values[?(@.alias == 'title' && @.culture == 'en-US' && @.segment == null)].value", + Path = "/values[alias=title,culture=en-US,segment=null]/value", Value = "Updated Title" } } @@ -462,13 +462,13 @@ public async Task PatchDocument_MultipleProperties_UpdatesAll() new PatchOperationRequestModel { Op = "replace", - Path = "$.values[?(@.alias == 'title' && @.culture == 'en-US' && @.segment == null)].value", + Path = "/values[alias=title,culture=en-US,segment=null]/value", Value = "Updated Title" }, new PatchOperationRequestModel { Op = "replace", - Path = "$.values[?(@.alias == 'description' && @.culture == 'en-US' && @.segment == null)].value", + Path = "/values[alias=description,culture=en-US,segment=null]/value", Value = "Updated Description" } } @@ -538,13 +538,13 @@ public async Task PatchDocument_PropertyAndVariant_UpdatesBoth() new PatchOperationRequestModel { Op = "replace", - Path = "$.variants[?(@.culture == 'en-US' && @.segment == null)].name", + Path = "/variants[culture=en-US,segment=null]/name", Value = "Updated Name" }, new PatchOperationRequestModel { Op = "replace", - Path = "$.values[?(@.alias == 'title' && @.culture == 'en-US' && @.segment == null)].value", + Path = "/values[alias=title,culture=en-US,segment=null]/value", Value = "Updated Title" } } @@ -602,7 +602,7 @@ public async Task PatchDocument_InvalidPropertyAlias_ReturnsBadRequest() var documentKey = createResponse.Result.Content.Key; // Try to patch non-existent property - // When JSONPath matches no elements, it returns BadRequest (400) not UnprocessableEntity (422) + // When path filter matches no elements, it returns BadRequest (400) var patchModel = new PatchDocumentRequestModel { Operations = new[] @@ -610,7 +610,7 @@ public async Task PatchDocument_InvalidPropertyAlias_ReturnsBadRequest() new PatchOperationRequestModel { Op = "replace", - Path = "$.values[?(@.alias == 'nonExistentProperty' && @.culture == 'en-US' && @.segment == null)].value", + Path = "/values[alias=nonExistentProperty,culture=en-US,segment=null]/value", Value = "Some Value" } } @@ -621,7 +621,7 @@ public async Task PatchDocument_InvalidPropertyAlias_ReturnsBadRequest() httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); - // Assert - Returns BadRequest (400) because JSONPath expression matches no elements + // Assert - Returns BadRequest (400) because path filter matches no elements Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); } @@ -676,7 +676,7 @@ public async Task PatchDocument_SegmentProperty_UpdatesCorrectSegment() new PatchOperationRequestModel { Op = "replace", - Path = "$.values[?(@.alias == 'price' && @.culture == 'en-US' && @.segment == 'premium')].value", + Path = "/values[alias=price,culture=en-US,segment=premium]/value", Value = "200" } } @@ -734,7 +734,7 @@ public async Task PatchDocument_DocumentInRecycleBin_AllowsPatch() new PatchOperationRequestModel { Op = "replace", - Path = "$.variants[?(@.culture == null && @.segment == null)].name", + Path = "/variants[culture=null,segment=null]/name", Value = "Updated Name" } } @@ -765,7 +765,7 @@ public async Task PatchDocument_NonExistentDocument_ReturnsNotFound() new PatchOperationRequestModel { Op = "replace", - Path = "$.variants[?(@.culture == null && @.segment == null)].name", + Path = "/variants[culture=null,segment=null]/name", Value = "Updated Name" } } @@ -917,9 +917,8 @@ public async Task PatchDocument_BlockList_SingleBlock_UpdatesSingleProperty() ContentService.Save(content); var documentKey = content.Key; - // Act - Patch only the headline of block 2 using nested JSONPath - // This demonstrates the TARGET API contract for Phase 8 implementation - // The JSONPath expression navigates through the nested structure: + // Act - Patch only the headline of block 2 using nested path + // The path expression navigates through the nested structure: // 1. Find the property with alias 'contentBlocks' // 2. Navigate into its .value (the BlockListValue object) // 3. Navigate into .contentData array @@ -927,7 +926,6 @@ public async Task PatchDocument_BlockList_SingleBlock_UpdatesSingleProperty() // 5. Navigate into its .values array // 6. Find the property with alias 'headline' // 7. Update its .value - // This enables minimal payload updates (~100 bytes) vs full replacement (~2KB) var patchModel = new PatchDocumentRequestModel { Operations = new[] @@ -935,7 +933,7 @@ public async Task PatchDocument_BlockList_SingleBlock_UpdatesSingleProperty() new PatchOperationRequestModel { Op = "replace", - Path = $"$.values[?(@.alias == 'contentBlocks' && @.culture == null && @.segment == null)].value.contentData[?(@.key == '{block2Key}')].values[?(@.alias == 'headline')].value", + Path = $"/values[alias=contentBlocks,culture=null,segment=null]/value/contentData[key={block2Key}]/values[alias=headline]/value", Value = "Updated Block 2 Headline" } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchEngineTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchEngineTests.cs new file mode 100644 index 000000000000..9de267fb9069 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchEngineTests.cs @@ -0,0 +1,472 @@ +using System.Text.Json; +using NUnit.Framework; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.PropertyEditors.Patching; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors.Patching; + +[TestFixture] +public class PatchEngineTests +{ + [Test] + public void Replace_SimpleProperty_UpdatesValue() + { + var json = """ + { + "name": "Original Name", + "title": "Original Title" + } + """; + + var result = PatchEngine.ApplyOperation(json, PatchOperationType.Replace, "/name", "Updated Name"); + + var doc = JsonDocument.Parse(result); + Assert.That(doc.RootElement.GetProperty("name").GetString(), Is.EqualTo("Updated Name")); + Assert.That(doc.RootElement.GetProperty("title").GetString(), Is.EqualTo("Original Title")); + } + + [Test] + public void Replace_ArrayElementProperty_UpdatesValue() + { + var json = """ + { + "values": [ + { "alias": "title", "value": "Original Value" }, + { "alias": "description", "value": "Description Value" } + ] + } + """; + + var result = PatchEngine.ApplyOperation( + json, PatchOperationType.Replace, + "/values[alias=title]/value", + "Updated Value"); + + var doc = JsonDocument.Parse(result); + var values = doc.RootElement.GetProperty("values").EnumerateArray().ToList(); + Assert.That(values[0].GetProperty("value").GetString(), Is.EqualTo("Updated Value")); + Assert.That(values[1].GetProperty("value").GetString(), Is.EqualTo("Description Value")); + } + + [Test] + public void Replace_NestedProperty_UpdatesValue() + { + var json = """ + { + "values": [ + { + "alias": "contentBlocks", + "value": { + "contentData": [ + { "key": "block-1", "values": [{ "alias": "headline", "value": "Original" }] }, + { "key": "block-2", "values": [{ "alias": "headline", "value": "Block 2 Headline" }] } + ] + } + } + ] + } + """; + + var result = PatchEngine.ApplyOperation( + json, + PatchOperationType.Replace, + "/values[alias=contentBlocks]/value/contentData[key=block-2]/values[alias=headline]/value", + "Updated Block 2 Headline"); + + var doc = JsonDocument.Parse(result); + var contentBlocks = doc.RootElement.GetProperty("values").EnumerateArray().First(); + var contentData = contentBlocks.GetProperty("value").GetProperty("contentData").EnumerateArray().ToList(); + var block2 = contentData.First(b => b.GetProperty("key").GetString() == "block-2"); + var headline = block2.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(headline.GetProperty("value").GetString(), Is.EqualTo("Updated Block 2 Headline")); + + // Verify block 1 wasn't changed + var block1 = contentData.First(b => b.GetProperty("key").GetString() == "block-1"); + var block1Headline = block1.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(block1Headline.GetProperty("value").GetString(), Is.EqualTo("Original")); + } + + [Test] + public void Replace_NullFilters_UpdatesValue() + { + var json = """ + { + "values": [ + { + "alias": "contentBlocks", + "culture": null, + "segment": null, + "value": { + "contentData": [ + { "key": "block-1", "values": [{ "alias": "headline", "value": "Block 1 Headline" }] }, + { "key": "block-2", "values": [{ "alias": "headline", "value": "Block 2 Headline" }] } + ] + } + } + ] + } + """; + + var result = PatchEngine.ApplyOperation( + json, + PatchOperationType.Replace, + "/values[alias=contentBlocks,culture=null,segment=null]/value/contentData[key=block-2]/values[alias=headline]/value", + "Updated Block 2 Headline"); + + var doc = JsonDocument.Parse(result); + var contentBlocks = doc.RootElement.GetProperty("values").EnumerateArray().First(); + var contentData = contentBlocks.GetProperty("value").GetProperty("contentData").EnumerateArray().ToList(); + + var block2 = contentData.First(b => b.GetProperty("key").GetString() == "block-2"); + var headline = block2.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(headline.GetProperty("value").GetString(), Is.EqualTo("Updated Block 2 Headline")); + + // Verify block 1 wasn't changed + var block1 = contentData.First(b => b.GetProperty("key").GetString() == "block-1"); + var block1Headline = block1.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(block1Headline.GetProperty("value").GetString(), Is.EqualTo("Block 1 Headline")); + } + + [Test] + public void Replace_NoMatches_ThrowsInvalidOperationException() + { + var json = """ + { + "values": [] + } + """; + + Assert.Throws(() => + PatchEngine.ApplyOperation(json, PatchOperationType.Replace, "/values[alias=nonexistent]/value", "New Value")); + } + + [Test] + public void Replace_RealBlockListStructure_UpdatesValue() + { + var blockKey = "25acc650-ab47-40ba-b31b-c5dc0a9eb5b1"; + var json = $$""" + { + "template" : null, + "values" : [ { + "culture" : null, + "segment" : null, + "alias" : "contentBlocks", + "value" : { + "contentData" : [ { + "contentTypeKey" : "98b8b6da-d9c1-47af-abd2-f99cf8150356", + "udi" : null, + "key" : "629e4129-3128-4367-9dac-d90edbfa68df", + "values" : [ { + "editorAlias" : "Umbraco.TextBox", + "culture" : null, + "segment" : null, + "alias" : "headline", + "value" : "Block 1 Headline" + } ] + }, { + "contentTypeKey" : "98b8b6da-d9c1-47af-abd2-f99cf8150356", + "udi" : null, + "key" : "{{blockKey}}", + "values" : [ { + "editorAlias" : "Umbraco.TextBox", + "culture" : null, + "segment" : null, + "alias" : "headline", + "value" : "Block 2 Headline" + } ] + } ], + "settingsData" : [ ], + "expose" : [ ], + "Layout" : { + "Umbraco.BlockList" : [ ] + } + } + } ], + "variants" : [ { + "culture" : null, + "segment" : null, + "name" : "My Blocks Document" + } ] + } + """; + + var path = $"/values[alias=contentBlocks,culture=null,segment=null]/value/contentData[key={blockKey}]/values[alias=headline]/value"; + + var result = PatchEngine.ApplyOperation(json, PatchOperationType.Replace, path, "Updated Block 2 Headline"); + + var doc = JsonDocument.Parse(result); + var values = doc.RootElement.GetProperty("values").EnumerateArray().First(); + var contentData = values.GetProperty("value").GetProperty("contentData").EnumerateArray().ToList(); + + var block2 = contentData.First(b => b.GetProperty("key").GetString() == blockKey); + var headline = block2.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(headline.GetProperty("value").GetString(), Is.EqualTo("Updated Block 2 Headline")); + + // Verify block 1 wasn't changed + var block1 = contentData.First(b => b.GetProperty("key").GetString() == "629e4129-3128-4367-9dac-d90edbfa68df"); + var block1Headline = block1.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); + Assert.That(block1Headline.GetProperty("value").GetString(), Is.EqualTo("Block 1 Headline")); + } + + [Test] + public void Add_AppendToArray_AddsElementAtEnd() + { + var json = """ + { + "contentData": [ + { "key": "block-1", "value": "first" } + ] + } + """; + + var newBlock = new { key = "block-2", value = "second" }; + + var result = PatchEngine.ApplyOperation(json, PatchOperationType.Add, "/contentData/-", newBlock); + + var doc = JsonDocument.Parse(result); + var contentData = doc.RootElement.GetProperty("contentData").EnumerateArray().ToList(); + Assert.That(contentData, Has.Count.EqualTo(2)); + Assert.That(contentData[0].GetProperty("key").GetString(), Is.EqualTo("block-1")); + Assert.That(contentData[1].GetProperty("key").GetString(), Is.EqualTo("block-2")); + } + + [Test] + public void Add_InsertAtIndex_InsertsElement() + { + var json = """ + { + "items": ["a", "b", "c"] + } + """; + + var result = PatchEngine.ApplyOperation(json, PatchOperationType.Add, "/items/1", "inserted"); + + var doc = JsonDocument.Parse(result); + var items = doc.RootElement.GetProperty("items").EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.That(items, Is.EqualTo(new[] { "a", "inserted", "b", "c" })); + } + + [Test] + public void Add_NewObjectProperty_AddsProperty() + { + var json = """ + { + "name": "Test" + } + """; + + var result = PatchEngine.ApplyOperation(json, PatchOperationType.Add, "/description", "New Description"); + + var doc = JsonDocument.Parse(result); + Assert.That(doc.RootElement.GetProperty("name").GetString(), Is.EqualTo("Test")); + Assert.That(doc.RootElement.GetProperty("description").GetString(), Is.EqualTo("New Description")); + } + + [Test] + public void Add_AppendToNestedArray_AddsElement() + { + var json = """ + { + "values": [ + { + "alias": "contentBlocks", + "culture": null, + "segment": null, + "value": { + "contentData": [ + { "key": "block-1", "values": [] } + ] + } + } + ] + } + """; + + var newBlock = new { key = "block-2", values = Array.Empty() }; + + var result = PatchEngine.ApplyOperation( + json, + PatchOperationType.Add, + "/values[alias=contentBlocks,culture=null,segment=null]/value/contentData/-", + newBlock); + + var doc = JsonDocument.Parse(result); + var contentData = doc.RootElement + .GetProperty("values").EnumerateArray().First() + .GetProperty("value") + .GetProperty("contentData").EnumerateArray().ToList(); + Assert.That(contentData, Has.Count.EqualTo(2)); + Assert.That(contentData[1].GetProperty("key").GetString(), Is.EqualTo("block-2")); + } + + [Test] + public void Remove_ArrayElementByFilter_RemovesElement() + { + var json = """ + { + "values": [ + { "alias": "title", "value": "Title Value" }, + { "alias": "description", "value": "Description Value" } + ] + } + """; + + var result = PatchEngine.ApplyOperation(json, PatchOperationType.Remove, "/values[alias=title]", null); + + var doc = JsonDocument.Parse(result); + var values = doc.RootElement.GetProperty("values").EnumerateArray().ToList(); + Assert.That(values, Has.Count.EqualTo(1)); + Assert.That(values[0].GetProperty("alias").GetString(), Is.EqualTo("description")); + } + + [Test] + public void Remove_ArrayElementByIndex_RemovesElement() + { + var json = """ + { + "items": ["a", "b", "c"] + } + """; + + var result = PatchEngine.ApplyOperation(json, PatchOperationType.Remove, "/items/1", null); + + var doc = JsonDocument.Parse(result); + var items = doc.RootElement.GetProperty("items").EnumerateArray().Select(e => e.GetString()).ToList(); + Assert.That(items, Is.EqualTo(new[] { "a", "c" })); + } + + [Test] + public void Remove_ObjectProperty_RemovesProperty() + { + var json = """ + { + "name": "Test", + "description": "To Remove" + } + """; + + var result = PatchEngine.ApplyOperation(json, PatchOperationType.Remove, "/description", null); + + var doc = JsonDocument.Parse(result); + Assert.That(doc.RootElement.GetProperty("name").GetString(), Is.EqualTo("Test")); + Assert.That(doc.RootElement.TryGetProperty("description", out _), Is.False); + } + + [Test] + public void Replace_WithAppendSegment_ThrowsInvalidOperationException() + { + var json = """ + { + "items": ["a", "b"] + } + """; + + Assert.Throws(() => + PatchEngine.ApplyOperation(json, PatchOperationType.Replace, "/items/-", "new")); + } + + [Test] + public void Remove_WithAppendSegment_ThrowsInvalidOperationException() + { + var json = """ + { + "items": ["a", "b"] + } + """; + + Assert.Throws(() => + PatchEngine.ApplyOperation(json, PatchOperationType.Remove, "/items/-", null)); + } + + [Test] + public void ApplyOperation_InvalidPath_ThrowsFormatException() + { + var json = """{ "name": "test" }"""; + + Assert.Throws(() => + PatchEngine.ApplyOperation(json, PatchOperationType.Replace, "name", "new")); + } + + [Test] + public void ApplyOperation_NullJson_ThrowsArgumentNullException() + { + Assert.Throws(() => + PatchEngine.ApplyOperation(null!, PatchOperationType.Replace, "/name", "new")); + } + + [Test] + public void Replace_OutputCanBeDeserialized() + { + var json = """ + { + "values": [ + { + "alias": "contentBlocks", + "culture": null, + "segment": null, + "value": { + "contentData": [ + { "key": "block-1", "values": [{ "alias": "headline", "value": "Block 1" }] }, + { "key": "block-2", "values": [{ "alias": "headline", "value": "Block 2" }] } + ], + "settingsData": [], + "expose": [], + "Layout": { "Umbraco.BlockList": [] } + } + } + ] + } + """; + + var result = PatchEngine.ApplyOperation( + json, + PatchOperationType.Replace, + "/values[alias=contentBlocks,culture=null,segment=null]/value/contentData[key=block-2]/values[alias=headline]/value", + "Updated Block 2"); + + // Verify the result is valid JSON + var doc = JsonDocument.Parse(result); + + // Extract the value property (simulates what JsonObjectConverter returns) + var valueElement = doc.RootElement.GetProperty("values").EnumerateArray().First().GetProperty("value"); + var valueJson = valueElement.GetRawText(); + + // Verify the modified value is in the JSON string + Assert.That(valueJson, Does.Contain("Updated Block 2")); + + // Parse and verify the structure is maintained + var reparsed = JsonDocument.Parse(valueJson); + var contentData = reparsed.RootElement.GetProperty("contentData").EnumerateArray().ToList(); + Assert.That(contentData, Has.Count.EqualTo(2)); + + var block2 = contentData.First(b => b.GetProperty("key").GetString() == "block-2"); + var headline = block2.GetProperty("values").EnumerateArray().First().GetProperty("value").GetString(); + Assert.That(headline, Is.EqualTo("Updated Block 2")); + } + + [Test] + public void Replace_MultipleFilterConditions_MatchesCorrectElement() + { + var json = """ + { + "values": [ + { "alias": "price", "culture": "en-US", "segment": "standard", "value": "100" }, + { "alias": "price", "culture": "en-US", "segment": "premium", "value": "150" }, + { "alias": "price", "culture": "da-DK", "segment": "premium", "value": "1000" } + ] + } + """; + + var result = PatchEngine.ApplyOperation( + json, + PatchOperationType.Replace, + "/values[alias=price,culture=en-US,segment=premium]/value", + "200"); + + var doc = JsonDocument.Parse(result); + var values = doc.RootElement.GetProperty("values").EnumerateArray().ToList(); + Assert.That(values[0].GetProperty("value").GetString(), Is.EqualTo("100")); + Assert.That(values[1].GetProperty("value").GetString(), Is.EqualTo("200")); + Assert.That(values[2].GetProperty("value").GetString(), Is.EqualTo("1000")); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchPathParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchPathParserTests.cs new file mode 100644 index 000000000000..eb21e83625d3 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchPathParserTests.cs @@ -0,0 +1,248 @@ +using NUnit.Framework; +using Umbraco.Cms.Core.PropertyEditors.Patching; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors.Patching; + +[TestFixture] +public class PatchPathParserTests +{ + [Test] + public void Parse_SimpleProperty_ReturnsPropertySegment() + { + var segments = PatchPathParser.Parse("/name"); + + Assert.That(segments, Has.Length.EqualTo(1)); + Assert.That(segments[0], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("name")); + } + + [Test] + public void Parse_TwoProperties_ReturnsTwoPropertySegments() + { + var segments = PatchPathParser.Parse("/variants/name"); + + Assert.That(segments, Has.Length.EqualTo(2)); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("variants")); + Assert.That(((PropertySegment)segments[1]).Name, Is.EqualTo("name")); + } + + [Test] + public void Parse_PropertyWithFilter_ReturnsPropertyAndFilterSegments() + { + var segments = PatchPathParser.Parse("/variants[culture=en-US,segment=null]/name"); + + Assert.That(segments, Has.Length.EqualTo(3)); + Assert.That(segments[0], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("variants")); + + Assert.That(segments[1], Is.InstanceOf()); + var filter = (FilterSegment)segments[1]; + Assert.That(filter.Conditions, Has.Length.EqualTo(2)); + Assert.That(filter.Conditions[0].Key, Is.EqualTo("culture")); + Assert.That(filter.Conditions[0].Value, Is.EqualTo("en-US")); + Assert.That(filter.Conditions[1].Key, Is.EqualTo("segment")); + Assert.That(filter.Conditions[1].Value, Is.Null); + + Assert.That(segments[2], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[2]).Name, Is.EqualTo("name")); + } + + [Test] + public void Parse_ValuesPath_ReturnsCorrectSegments() + { + var segments = PatchPathParser.Parse("/values[alias=title,culture=en-US,segment=null]/value"); + + Assert.That(segments, Has.Length.EqualTo(3)); + + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("values")); + + var filter = (FilterSegment)segments[1]; + Assert.That(filter.Conditions, Has.Length.EqualTo(3)); + Assert.That(filter.Conditions[0], Is.EqualTo(new FilterCondition("alias", "title"))); + Assert.That(filter.Conditions[1], Is.EqualTo(new FilterCondition("culture", "en-US"))); + Assert.That(filter.Conditions[2], Is.EqualTo(new FilterCondition("segment", null))); + + Assert.That(((PropertySegment)segments[2]).Name, Is.EqualTo("value")); + } + + [Test] + public void Parse_NestedBlockListPath_ReturnsCorrectSegments() + { + var path = "/values[alias=contentBlocks,culture=null,segment=null]/value/contentData[key=block-2]/values[alias=headline]/value"; + var segments = PatchPathParser.Parse(path); + + Assert.That(segments, Has.Length.EqualTo(8)); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("values")); + Assert.That(segments[1], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[2]).Name, Is.EqualTo("value")); + Assert.That(((PropertySegment)segments[3]).Name, Is.EqualTo("contentData")); + Assert.That(segments[4], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[5]).Name, Is.EqualTo("values")); + Assert.That(segments[6], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[7]).Name, Is.EqualTo("value")); + } + + [Test] + public void Parse_IndexSegment_ReturnsIndexSegment() + { + var segments = PatchPathParser.Parse("/contentData/2/values"); + + Assert.That(segments, Has.Length.EqualTo(3)); + Assert.That(segments[0], Is.InstanceOf()); + Assert.That(segments[1], Is.InstanceOf()); + Assert.That(((IndexSegment)segments[1]).Index, Is.EqualTo(2)); + Assert.That(segments[2], Is.InstanceOf()); + } + + [Test] + public void Parse_AppendSegment_ReturnsAppendSegment() + { + var segments = PatchPathParser.Parse("/contentData/-"); + + Assert.That(segments, Has.Length.EqualTo(2)); + Assert.That(segments[0], Is.InstanceOf()); + Assert.That(segments[1], Is.InstanceOf()); + } + + [Test] + public void Parse_EmptyString_ThrowsFormatException() + { + Assert.Throws(() => PatchPathParser.Parse(string.Empty)); + } + + [Test] + public void Parse_NoLeadingSlash_ThrowsFormatException() + { + Assert.Throws(() => PatchPathParser.Parse("name")); + } + + [Test] + public void Parse_UnclosedBracket_ThrowsFormatException() + { + Assert.Throws(() => PatchPathParser.Parse("/values[alias=title")); + } + + [Test] + public void Parse_EmptyFilter_ThrowsFormatException() + { + Assert.Throws(() => PatchPathParser.Parse("/values[]")); + } + + [Test] + public void Parse_FilterWithoutEquals_ThrowsFormatException() + { + Assert.Throws(() => PatchPathParser.Parse("/values[alias]")); + } + + [Test] + public void IsValid_ValidSimplePath_ReturnsTrue() + { + Assert.That(PatchPathParser.IsValid("/name"), Is.True); + } + + [Test] + public void IsValid_ValidFilterPath_ReturnsTrue() + { + Assert.That(PatchPathParser.IsValid("/values[alias=title,culture=en-US,segment=null]/value"), Is.True); + } + + [Test] + public void IsValid_ValidAppendPath_ReturnsTrue() + { + Assert.That(PatchPathParser.IsValid("/contentData/-"), Is.True); + } + + [Test] + public void IsValid_EmptyString_ReturnsFalse() + { + Assert.That(PatchPathParser.IsValid(string.Empty), Is.False); + } + + [Test] + public void IsValid_InvalidSyntax_ReturnsFalse() + { + Assert.That(PatchPathParser.IsValid("/values[alias=title"), Is.False); + } + + [Test] + public void ExtractCultures_PathWithCulture_ReturnsCulture() + { + var cultures = PatchPathParser.ExtractCultures("/values[alias=title,culture=en-US,segment=null]/value"); + + Assert.That(cultures, Has.Count.EqualTo(1)); + Assert.That(cultures, Does.Contain("en-US")); + } + + [Test] + public void ExtractCultures_PathWithNullCulture_ReturnsEmpty() + { + var cultures = PatchPathParser.ExtractCultures("/values[alias=title,culture=null,segment=null]/value"); + + Assert.That(cultures, Is.Empty); + } + + [Test] + public void ExtractCultures_PathWithoutCulture_ReturnsEmpty() + { + var cultures = PatchPathParser.ExtractCultures("/values[alias=title]/value"); + + Assert.That(cultures, Is.Empty); + } + + [Test] + public void ExtractCultures_InvalidPath_ReturnsEmpty() + { + var cultures = PatchPathParser.ExtractCultures(string.Empty); + + Assert.That(cultures, Is.Empty); + } + + [Test] + public void ExtractSegments_PathWithSegment_ReturnsSegment() + { + var segments = PatchPathParser.ExtractSegments("/values[alias=price,culture=en-US,segment=premium]/value"); + + Assert.That(segments, Has.Count.EqualTo(1)); + Assert.That(segments, Does.Contain("premium")); + } + + [Test] + public void ExtractSegments_PathWithNullSegment_ReturnsEmpty() + { + var segments = PatchPathParser.ExtractSegments("/values[alias=price,segment=null]/value"); + + Assert.That(segments, Is.Empty); + } + + [Test] + public void ExtractSegments_PathWithoutSegment_ReturnsEmpty() + { + var segments = PatchPathParser.ExtractSegments("/values[alias=title]/value"); + + Assert.That(segments, Is.Empty); + } + + [Test] + public void TargetsInvariantCulture_NullCulture_ReturnsTrue() + { + Assert.That(PatchPathParser.TargetsInvariantCulture("/values[alias=title,culture=null]/value"), Is.True); + } + + [Test] + public void TargetsInvariantCulture_SpecificCulture_ReturnsFalse() + { + Assert.That(PatchPathParser.TargetsInvariantCulture("/values[alias=title,culture=en-US]/value"), Is.False); + } + + [Test] + public void TargetsInvariantCulture_NoCulture_ReturnsFalse() + { + Assert.That(PatchPathParser.TargetsInvariantCulture("/values[alias=title]/value"), Is.False); + } + + [Test] + public void TargetsInvariantCulture_InvalidPath_ReturnsFalse() + { + Assert.That(PatchPathParser.TargetsInvariantCulture(string.Empty), Is.False); + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathCultureExtractorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathCultureExtractorTests.cs index 5cb332f5179f..de196c1e1cbc 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathCultureExtractorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathCultureExtractorTests.cs @@ -15,45 +15,25 @@ public void SetUp() } [Test] - public void ExtractCultures_SingleCultureWithSingleQuotes_ReturnsCulture() + public void ExtractCultures_SingleCulture_ReturnsCulture() { - // Arrange - var path = "$.values[?(@.alias == 'title' && @.culture == 'en-US')]"; + var path = "/values[alias=title,culture=en-US]/value"; - // Act var cultures = _extractor.ExtractCultures(path); - // Assert Assert.That(cultures, Has.Count.EqualTo(1)); Assert.That(cultures, Does.Contain("en-US")); } [Test] - public void ExtractCultures_SingleCultureWithDoubleQuotes_ReturnsCulture() + public void ExtractCultures_MultiplePaths_ReturnAllCultures() { - // Arrange - var path = """$.values[?(@.alias == "title" && @.culture == "en-US")]"""; + var path1 = "/values[culture=en-US]/value"; + var path2 = "/values[culture=da-DK]/value"; - // Act - var cultures = _extractor.ExtractCultures(path); - - // Assert - Assert.That(cultures, Has.Count.EqualTo(1)); - Assert.That(cultures, Does.Contain("en-US")); - } - - [Test] - public void ExtractCultures_MultipleCultures_ReturnsAllCultures() - { - // Arrange - var path1 = "$.values[?(@.culture == 'en-US')]"; - var path2 = "$.values[?(@.culture == 'da-DK')]"; - - // Act var cultures1 = _extractor.ExtractCultures(path1); var cultures2 = _extractor.ExtractCultures(path2); - // Assert Assert.That(cultures1, Does.Contain("en-US")); Assert.That(cultures2, Does.Contain("da-DK")); } @@ -61,36 +41,28 @@ public void ExtractCultures_MultipleCultures_ReturnsAllCultures() [Test] public void ExtractCultures_NoCulture_ReturnsEmpty() { - // Arrange - var path = "$.values[?(@.alias == 'title')]"; + var path = "/values[alias=title]/value"; - // Act var cultures = _extractor.ExtractCultures(path); - // Assert Assert.That(cultures, Is.Empty); } [Test] public void ExtractCultures_EmptyString_ReturnsEmpty() { - // Act var cultures = _extractor.ExtractCultures(string.Empty); - // Assert Assert.That(cultures, Is.Empty); } [Test] public void ExtractSegments_SingleSegment_ReturnsSegment() { - // Arrange - var path = "$.values[?(@.alias == 'price' && @.segment == 'premium')]"; + var path = "/values[alias=price,segment=premium]/value"; - // Act var segments = _extractor.ExtractSegments(path); - // Assert Assert.That(segments, Has.Count.EqualTo(1)); Assert.That(segments, Does.Contain("premium")); } @@ -98,96 +70,75 @@ public void ExtractSegments_SingleSegment_ReturnsSegment() [Test] public void ExtractSegments_NoSegment_ReturnsEmpty() { - // Arrange - var path = "$.values[?(@.alias == 'title')]"; + var path = "/values[alias=title]/value"; - // Act var segments = _extractor.ExtractSegments(path); - // Assert Assert.That(segments, Is.Empty); } [Test] public void ContainsInvariantCultureFilter_NullCulture_ReturnsTrue() { - // Arrange - var path = "$.values[?(@.alias == 'title' && @.culture == null)]"; + var path = "/values[alias=title,culture=null]/value"; - // Act var containsInvariant = _extractor.ContainsInvariantCultureFilter(path); - // Assert Assert.That(containsInvariant, Is.True); } [Test] public void ContainsInvariantCultureFilter_CultureValue_ReturnsFalse() { - // Arrange - var path = "$.values[?(@.alias == 'title' && @.culture == 'en-US')]"; + var path = "/values[alias=title,culture=en-US]/value"; - // Act var containsInvariant = _extractor.ContainsInvariantCultureFilter(path); - // Assert Assert.That(containsInvariant, Is.False); } [Test] public void ContainsInvariantCultureFilter_NoCultureFilter_ReturnsFalse() { - // Arrange - var path = "$.values[?(@.alias == 'title')]"; + var path = "/values[alias=title]/value"; - // Act var containsInvariant = _extractor.ContainsInvariantCultureFilter(path); - // Assert Assert.That(containsInvariant, Is.False); } [Test] public void ContainsNullSegmentFilter_NullSegment_ReturnsTrue() { - // Arrange - var path = "$.values[?(@.alias == 'price' && @.segment == null)]"; + var path = "/values[alias=price,segment=null]/value"; - // Act var containsNullSegment = _extractor.ContainsNullSegmentFilter(path); - // Assert Assert.That(containsNullSegment, Is.True); } [Test] public void ContainsNullSegmentFilter_SegmentValue_ReturnsFalse() { - // Arrange - var path = "$.values[?(@.alias == 'price' && @.segment == 'premium')]"; + var path = "/values[alias=price,segment=premium]/value"; - // Act var containsNullSegment = _extractor.ContainsNullSegmentFilter(path); - // Assert Assert.That(containsNullSegment, Is.False); } [Test] public void ExtractCulturesFromOperations_MultipleOperations_ReturnsAllUniqueCultures() { - // Arrange var operations = new[] { - "$.values[?(@.culture == 'en-US')]", - "$.values[?(@.culture == 'da-DK')]", - "$.values[?(@.culture == 'en-US')]" // Duplicate + "/values[culture=en-US]/value", + "/values[culture=da-DK]/value", + "/values[culture=en-US]/value" // Duplicate }; - // Act var cultures = _extractor.ExtractCulturesFromOperations(operations); - // Assert Assert.That(cultures, Has.Count.EqualTo(2)); Assert.That(cultures, Does.Contain("en-US")); Assert.That(cultures, Does.Contain("da-DK")); @@ -196,70 +147,46 @@ public void ExtractCulturesFromOperations_MultipleOperations_ReturnsAllUniqueCul [Test] public void ExtractCulturesFromOperations_EmptyCollection_ReturnsEmpty() { - // Act var cultures = _extractor.ExtractCulturesFromOperations(Array.Empty()); - // Assert Assert.That(cultures, Is.Empty); } [Test] public void AnyOperationTargetsInvariantCulture_ContainsInvariant_ReturnsTrue() { - // Arrange var operations = new[] { - "$.values[?(@.culture == 'en-US')]", - "$.values[?(@.culture == null)]" + "/values[culture=en-US]/value", + "/values[culture=null]/value" }; - // Act var targetsInvariant = _extractor.AnyOperationTargetsInvariantCulture(operations); - // Assert Assert.That(targetsInvariant, Is.True); } [Test] public void AnyOperationTargetsInvariantCulture_NoInvariant_ReturnsFalse() { - // Arrange var operations = new[] { - "$.values[?(@.culture == 'en-US')]", - "$.values[?(@.culture == 'da-DK')]" + "/values[culture=en-US]/value", + "/values[culture=da-DK]/value" }; - // Act var targetsInvariant = _extractor.AnyOperationTargetsInvariantCulture(operations); - // Assert Assert.That(targetsInvariant, Is.False); } - [Test] - public void ExtractCultures_CaseInsensitive_ReturnsUniqueCultures() - { - // Arrange - var path = "$.values[?(@.culture == 'en-US' || @.culture == 'EN-US')]"; - - // Act - var cultures = _extractor.ExtractCultures(path); - - // Assert - Should return 1 unique culture (case-insensitive comparison) - Assert.That(cultures, Has.Count.EqualTo(1)); - } - [Test] public void ExtractCultures_ComplexNestedPath_ExtractsCulture() { - // Arrange - var path = "$.values[?(@.alias == 'contentBlocks')].value.contentData[?(@.key == 'guid')].values[?(@.alias == 'headline' && @.culture == 'en-US')]"; + var path = "/values[alias=contentBlocks]/value/contentData[key=some-guid]/values[alias=headline,culture=en-US]/value"; - // Act var cultures = _extractor.ExtractCultures(path); - // Assert Assert.That(cultures, Has.Count.EqualTo(1)); Assert.That(cultures, Does.Contain("en-US")); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathEvaluatorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathEvaluatorTests.cs deleted file mode 100644 index a16b0c5c3a74..000000000000 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathEvaluatorTests.cs +++ /dev/null @@ -1,653 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using NUnit.Framework; -using Umbraco.Cms.Core.Models.ContentEditing; -using Umbraco.Cms.Core.PropertyEditors.JsonPath; - -namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.PropertyEditors.JsonPath; - -[TestFixture] -public class JsonPathEvaluatorTests -{ - private JsonPathEvaluator _evaluator = null!; - - [SetUp] - public void SetUp() - { - _evaluator = new JsonPathEvaluator(); - } - - [Test] - public void Select_SimpleProperty_ReturnsMatchingElement() - { - // Arrange - var json = """ - { - "name": "Test Document", - "title": "Test Title" - } - """; - var doc = JsonDocument.Parse(json); - - // Act - var results = _evaluator.Select(doc, "$.name"); - - // Assert - Assert.That(results, Has.Count.EqualTo(1)); - Assert.That(results[0].GetString(), Is.EqualTo("Test Document")); - } - - [Test] - public void Select_ArrayFilterByProperty_ReturnsMatchingElements() - { - // Arrange - var json = """ - { - "values": [ - { "alias": "title", "value": "Title Value" }, - { "alias": "description", "value": "Description Value" } - ] - } - """; - var doc = JsonDocument.Parse(json); - - // Act - var results = _evaluator.Select(doc, "$.values[?(@.alias == 'title')]"); - - // Assert - Assert.That(results, Has.Count.EqualTo(1)); - Assert.That(results[0].GetProperty("value").GetString(), Is.EqualTo("Title Value")); - } - - [Test] - public void Select_MultipleFilters_ReturnsMatchingElements() - { - // Arrange - var json = """ - { - "values": [ - { "alias": "title", "culture": "en-US", "value": "English Title" }, - { "alias": "title", "culture": "da-DK", "value": "Danish Title" }, - { "alias": "description", "culture": "en-US", "value": "English Description" } - ] - } - """; - var doc = JsonDocument.Parse(json); - - // Act - var results = _evaluator.Select(doc, "$.values[?(@.alias == 'title' && @.culture == 'en-US')]"); - - // Assert - Assert.That(results, Has.Count.EqualTo(1)); - Assert.That(results[0].GetProperty("value").GetString(), Is.EqualTo("English Title")); - } - - [Test] - public void Select_NestedPath_ReturnsMatchingElements() - { - // Arrange - var json = """ - { - "values": [ - { - "alias": "contentBlocks", - "value": { - "contentData": [ - { "key": "12345678-1234-1234-1234-123456789012", "values": [{ "alias": "headline", "value": "Block Headline" }] } - ] - } - } - ] - } - """; - var doc = JsonDocument.Parse(json); - - // Act - var results = _evaluator.Select(doc, "$.values[?(@.alias == 'contentBlocks')].value.contentData[?(@.key == '12345678-1234-1234-1234-123456789012')].values[?(@.alias == 'headline')]"); - - // Assert - Assert.That(results, Has.Count.EqualTo(1)); - Assert.That(results[0].GetProperty("value").GetString(), Is.EqualTo("Block Headline")); - } - - [Test] - public void Select_NoMatches_ReturnsEmptyList() - { - // Arrange - var json = """ - { - "values": [ - { "alias": "title", "value": "Title Value" } - ] - } - """; - var doc = JsonDocument.Parse(json); - - // Act - var results = _evaluator.Select(doc, "$.values[?(@.alias == 'nonexistent')]"); - - // Assert - Assert.That(results, Is.Empty); - } - - [Test] - public void SelectSingle_ReturnsFirstMatch() - { - // Arrange - var json = """ - { - "values": [ - { "alias": "title", "value": "First" }, - { "alias": "title", "value": "Second" } - ] - } - """; - var doc = JsonDocument.Parse(json); - - // Act - var result = _evaluator.SelectSingle(doc, "$.values[?(@.alias == 'title')]"); - - // Assert - Assert.That(result, Is.Not.Null); - Assert.That(result!.Value.GetProperty("value").GetString(), Is.EqualTo("First")); - } - - [Test] - public void SelectSingle_NoMatches_ReturnsNull() - { - // Arrange - var json = """ - { - "values": [] - } - """; - var doc = JsonDocument.Parse(json); - - // Act - var result = _evaluator.SelectSingle(doc, "$.values[?(@.alias == 'title')]"); - - // Assert - Assert.That(result, Is.Null); - } - - [Test] - public void Exists_MatchFound_ReturnsTrue() - { - // Arrange - var json = """ - { - "values": [ - { "alias": "title", "value": "Title Value" } - ] - } - """; - var doc = JsonDocument.Parse(json); - - // Act - var exists = _evaluator.Exists(doc, "$.values[?(@.alias == 'title')]"); - - // Assert - Assert.That(exists, Is.True); - } - - [Test] - public void Exists_NoMatch_ReturnsFalse() - { - // Arrange - var json = """ - { - "values": [] - } - """; - var doc = JsonDocument.Parse(json); - - // Act - var exists = _evaluator.Exists(doc, "$.values[?(@.alias == 'title')]"); - - // Assert - Assert.That(exists, Is.False); - } - - [Test] - public void IsValidExpression_ValidExpression_ReturnsTrue() - { - // Act - var isValid = _evaluator.IsValidExpression("$.values[?(@.alias == 'title')]"); - - // Assert - Assert.That(isValid, Is.True); - } - - [Test] - public void IsValidExpression_InvalidExpression_ReturnsFalse() - { - // Act - var isValid = _evaluator.IsValidExpression("$.values[?(@.alias == "); - - // Assert - Assert.That(isValid, Is.False); - } - - [Test] - public void IsValidExpression_EmptyString_ReturnsFalse() - { - // Act - var isValid = _evaluator.IsValidExpression(string.Empty); - - // Assert - Assert.That(isValid, Is.False); - } - - [Test] - public void Select_NullDocument_ThrowsArgumentNullException() - { - // Act & Assert - Assert.Throws(() => _evaluator.Select(null!, "$.name")); - } - - [Test] - public void Select_EmptyExpression_ThrowsArgumentException() - { - // Arrange - var json = "{}"; - var doc = JsonDocument.Parse(json); - - // Act & Assert - Assert.Throws(() => _evaluator.Select(doc, string.Empty)); - } - - [Test] - public void Select_ThreeFiltersWithSegment_ReturnsMatchingElements() - { - // Arrange - var json = """ - { - "values": [ - { "alias": "price", "culture": "en-US", "segment": "standard", "value": "100" }, - { "alias": "price", "culture": "en-US", "segment": "premium", "value": "150" }, - { "alias": "price", "culture": "da-DK", "segment": "premium", "value": "1000" } - ] - } - """; - var doc = JsonDocument.Parse(json); - - // Act - var results = _evaluator.Select(doc, "$.values[?(@.alias == 'price' && @.culture == 'en-US' && @.segment == 'premium')]"); - - // Assert - Assert.That(results, Has.Count.EqualTo(1)); - Assert.That(results[0].GetProperty("value").GetString(), Is.EqualTo("150")); - } - - [Test] - public void IsValidExpression_ThreeFiltersWithSegment_ReturnsTrue() - { - // Act - var isValid = _evaluator.IsValidExpression("$.values[?(@.alias == 'price' && @.culture == 'en-US' && @.segment == 'premium')].value"); - - // Assert - Assert.That(isValid, Is.True); - } - - [Test] - public void ApplyOperation_ReplaceSimpleProperty_UpdatesValue() - { - // Arrange - var json = """ - { - "name": "Original Name", - "title": "Original Title" - } - """; - - // Act - var result = _evaluator.ApplyOperation(json, PatchOperationType.Replace, "$.name", "Updated Name"); - - // Assert - var doc = JsonDocument.Parse(result); - Assert.That(doc.RootElement.GetProperty("name").GetString(), Is.EqualTo("Updated Name")); - Assert.That(doc.RootElement.GetProperty("title").GetString(), Is.EqualTo("Original Title")); - } - - [Test] - public void ApplyOperation_ReplaceArrayElementProperty_UpdatesValue() - { - // Arrange - var json = """ - { - "values": [ - { "alias": "title", "value": "Original Value" }, - { "alias": "description", "value": "Description Value" } - ] - } - """; - - // Act - var result = _evaluator.ApplyOperation(json, PatchOperationType.Replace, "$.values[?(@.alias == 'title')].value", "Updated Value"); - - // Assert - var doc = JsonDocument.Parse(result); - var values = doc.RootElement.GetProperty("values").EnumerateArray().ToList(); - Assert.That(values[0].GetProperty("value").GetString(), Is.EqualTo("Updated Value")); - Assert.That(values[1].GetProperty("value").GetString(), Is.EqualTo("Description Value")); - } - - [Test] - public void ApplyOperation_ReplaceNestedProperty_UpdatesValue() - { - // Arrange - var json = """ - { - "values": [ - { - "alias": "contentBlocks", - "value": { - "contentData": [ - { "key": "block-1", "values": [{ "alias": "headline", "value": "Original" }] }, - { "key": "block-2", "values": [{ "alias": "headline", "value": "Block 2 Headline" }] } - ] - } - } - ] - } - """; - - // Act - var result = _evaluator.ApplyOperation( - json, - PatchOperationType.Replace, - "$.values[?(@.alias == 'contentBlocks')].value.contentData[?(@.key == 'block-2')].values[?(@.alias == 'headline')].value", - "Updated Block 2 Headline"); - - // Assert - var doc = JsonDocument.Parse(result); - var contentBlocks = doc.RootElement.GetProperty("values").EnumerateArray().First(); - var contentData = contentBlocks.GetProperty("value").GetProperty("contentData").EnumerateArray().ToList(); - var block2 = contentData.First(b => b.GetProperty("key").GetString() == "block-2"); - var headline = block2.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); - Assert.That(headline.GetProperty("value").GetString(), Is.EqualTo("Updated Block 2 Headline")); - - // Verify block 1 wasn't changed - var block1 = contentData.First(b => b.GetProperty("key").GetString() == "block-1"); - var block1Headline = block1.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); - Assert.That(block1Headline.GetProperty("value").GetString(), Is.EqualTo("Original")); - } - - [Test] - public void ApplyOperation_NoMatches_ThrowsInvalidOperationException() - { - // Arrange - var json = """ - { - "values": [] - } - """; - - // Act & Assert - Assert.Throws(() => - _evaluator.ApplyOperation(json, PatchOperationType.Replace, "$.values[?(@.alias == 'nonexistent')].value", "New Value")); - } - - [Test] - public void Select_NullComparison_ReturnsMatchingElements() - { - // Arrange - Test null comparison in JSONPath filter - var json = """ - { - "values": [ - { "alias": "title", "culture": null, "segment": null, "value": "Invariant Value" }, - { "alias": "title", "culture": "en-US", "segment": null, "value": "English Value" } - ] - } - """; - var doc = JsonDocument.Parse(json); - - // Act - Filter by null culture - var results = _evaluator.Select(doc, "$.values[?(@.alias == 'title' && @.culture == null)]"); - - // Assert - Assert.That(results, Has.Count.EqualTo(1)); - Assert.That(results[0].GetProperty("value").GetString(), Is.EqualTo("Invariant Value")); - } - - [Test] - public void Select_MultipleNullComparisons_ReturnsMatchingElements() - { - // Arrange - Test multiple null comparisons - var json = """ - { - "values": [ - { "alias": "title", "culture": null, "segment": null, "value": "Invariant" }, - { "alias": "title", "culture": null, "segment": "premium", "value": "Invariant Premium" }, - { "alias": "title", "culture": "en-US", "segment": null, "value": "English" } - ] - } - """; - var doc = JsonDocument.Parse(json); - - // Act - Filter by both null culture and null segment - var results = _evaluator.Select(doc, "$.values[?(@.alias == 'title' && @.culture == null && @.segment == null)]"); - - // Assert - Assert.That(results, Has.Count.EqualTo(1)); - Assert.That(results[0].GetProperty("value").GetString(), Is.EqualTo("Invariant")); - } - - [Test] - public void ApplyOperation_ReplaceWithNullFilters_UpdatesValue() - { - // Arrange - Test replacement with null filters (exact scenario from Block List test) - var json = """ - { - "values": [ - { - "alias": "contentBlocks", - "culture": null, - "segment": null, - "value": { - "contentData": [ - { "key": "block-1", "values": [{ "alias": "headline", "value": "Block 1 Headline" }] }, - { "key": "block-2", "values": [{ "alias": "headline", "value": "Block 2 Headline" }] } - ] - } - } - ] - } - """; - - // Act - var result = _evaluator.ApplyOperation( - json, - PatchOperationType.Replace, - "$.values[?(@.alias == 'contentBlocks' && @.culture == null && @.segment == null)].value.contentData[?(@.key == 'block-2')].values[?(@.alias == 'headline')].value", - "Updated Block 2 Headline"); - - // Assert - var doc = JsonDocument.Parse(result); - var contentBlocks = doc.RootElement.GetProperty("values").EnumerateArray().First(); - var contentData = contentBlocks.GetProperty("value").GetProperty("contentData").EnumerateArray().ToList(); - - var block2 = contentData.First(b => b.GetProperty("key").GetString() == "block-2"); - var headline = block2.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); - Assert.That(headline.GetProperty("value").GetString(), Is.EqualTo("Updated Block 2 Headline")); - - // Verify block 1 wasn't changed - var block1 = contentData.First(b => b.GetProperty("key").GetString() == "block-1"); - var block1Headline = block1.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); - Assert.That(block1Headline.GetProperty("value").GetString(), Is.EqualTo("Block 1 Headline")); - } - - [Test] - public void ApplyOperation_RealBlockListStructure_UpdatesValue() - { - // Arrange - Use the exact JSON structure from the integration test - var blockKey = "25acc650-ab47-40ba-b31b-c5dc0a9eb5b1"; - var json = $$""" - { - "template" : null, - "values" : [ { - "culture" : null, - "segment" : null, - "alias" : "contentBlocks", - "value" : { - "contentData" : [ { - "contentTypeKey" : "98b8b6da-d9c1-47af-abd2-f99cf8150356", - "udi" : null, - "key" : "629e4129-3128-4367-9dac-d90edbfa68df", - "values" : [ { - "editorAlias" : "Umbraco.TextBox", - "culture" : null, - "segment" : null, - "alias" : "headline", - "value" : "Block 1 Headline" - } ] - }, { - "contentTypeKey" : "98b8b6da-d9c1-47af-abd2-f99cf8150356", - "udi" : null, - "key" : "{{blockKey}}", - "values" : [ { - "editorAlias" : "Umbraco.TextBox", - "culture" : null, - "segment" : null, - "alias" : "headline", - "value" : "Block 2 Headline" - } ] - } ], - "settingsData" : [ ], - "expose" : [ ], - "Layout" : { - "Umbraco.BlockList" : [ ] - } - } - } ], - "variants" : [ { - "culture" : null, - "segment" : null, - "name" : "My Blocks Document" - } ] - } - """; - - var path = $"$.values[?(@.alias == 'contentBlocks' && @.culture == null && @.segment == null)].value.contentData[?(@.key == '{blockKey}')].values[?(@.alias == 'headline')].value"; - - // Act - var result = _evaluator.ApplyOperation(json, PatchOperationType.Replace, path, "Updated Block 2 Headline"); - - // Assert - var doc = JsonDocument.Parse(result); - var values = doc.RootElement.GetProperty("values").EnumerateArray().First(); - var contentData = values.GetProperty("value").GetProperty("contentData").EnumerateArray().ToList(); - - // Find block 2 by key - var block2 = contentData.First(b => b.GetProperty("key").GetString() == blockKey); - var headline = block2.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); - Assert.That(headline.GetProperty("value").GetString(), Is.EqualTo("Updated Block 2 Headline")); - - // Verify block 1 wasn't changed - var block1 = contentData.First(b => b.GetProperty("key").GetString() == "629e4129-3128-4367-9dac-d90edbfa68df"); - var block1Headline = block1.GetProperty("values").EnumerateArray().First(v => v.GetProperty("alias").GetString() == "headline"); - Assert.That(block1Headline.GetProperty("value").GetString(), Is.EqualTo("Block 1 Headline")); - } - - [Test] - public void JsonObject_ToString_ReturnsValidJson() - { - // This test verifies that JsonObject.ToString() returns valid JSON - // which is critical for BlockEditorValues.DeserializeAndClean - var json = """ - { - "contentData": [ - { "key": "block-1", "values": [{ "alias": "headline", "value": "Block 1 Headline" }] }, - { "key": "block-2", "values": [{ "alias": "headline", "value": "Block 2 Headline" }] } - ], - "settingsData": [], - "expose": [], - "Layout": { "Umbraco.BlockList": [] } - } - """; - - // Parse to JsonObject (simulates what JsonObjectConverter returns) - var jsonObject = JsonNode.Parse(json) as JsonObject; - Assert.That(jsonObject, Is.Not.Null); - - // Verify ToString() returns valid JSON - var toStringResult = jsonObject!.ToString(); - Assert.That(toStringResult, Is.Not.Null.And.Not.Empty); - - // Verify ToJsonString() also works - var toJsonStringResult = jsonObject.ToJsonString(); - Assert.That(toJsonStringResult, Is.Not.Null.And.Not.Empty); - - // Verify both can be parsed back to valid JSON - var reparsedFromToString = JsonDocument.Parse(toStringResult); - Assert.That(reparsedFromToString.RootElement.GetProperty("contentData").GetArrayLength(), Is.EqualTo(2)); - - var reparsedFromToJsonString = JsonDocument.Parse(toJsonStringResult); - Assert.That(reparsedFromToJsonString.RootElement.GetProperty("contentData").GetArrayLength(), Is.EqualTo(2)); - - // Verify both produce equivalent output - Console.WriteLine($"ToString(): {toStringResult}"); - Console.WriteLine($"ToJsonString(): {toJsonStringResult}"); - } - - [Test] - public void ApplyOperation_VerifyOutputCanBeDeserialized() - { - // This test verifies the full flow - apply operation, then verify the resulting - // JSON can be correctly deserialized by downstream systems - var json = """ - { - "values": [ - { - "alias": "contentBlocks", - "culture": null, - "segment": null, - "value": { - "contentData": [ - { "key": "block-1", "values": [{ "alias": "headline", "value": "Block 1" }] }, - { "key": "block-2", "values": [{ "alias": "headline", "value": "Block 2" }] } - ], - "settingsData": [], - "expose": [], - "Layout": { "Umbraco.BlockList": [] } - } - } - ] - } - """; - - // Act - Apply operation - var result = _evaluator.ApplyOperation( - json, - PatchOperationType.Replace, - "$.values[?(@.alias == 'contentBlocks' && @.culture == null && @.segment == null)].value.contentData[?(@.key == 'block-2')].values[?(@.alias == 'headline')].value", - "Updated Block 2"); - - // Verify the result is valid JSON - var doc = JsonDocument.Parse(result); - - // Extract the value property (which would be deserialized as JsonObject) - var valueElement = doc.RootElement.GetProperty("values").EnumerateArray().First().GetProperty("value"); - var valueJson = valueElement.GetRawText(); - - // Parse as JsonObject (simulates JsonObjectConverter behavior) - var valueAsJsonObject = JsonNode.Parse(valueJson) as JsonObject; - Assert.That(valueAsJsonObject, Is.Not.Null); - - // Verify ToString() produces valid JSON that can be parsed by BlockEditorValues - var valueAsString = valueAsJsonObject!.ToString(); - Console.WriteLine($"Value as string: {valueAsString}"); - - // Verify the modified value is in the JSON string - Assert.That(valueAsString, Does.Contain("Updated Block 2")); - - // Parse and verify the structure is maintained - var reparsed = JsonDocument.Parse(valueAsString); - var contentData = reparsed.RootElement.GetProperty("contentData").EnumerateArray().ToList(); - Assert.That(contentData, Has.Count.EqualTo(2)); - - var block2 = contentData.First(b => b.GetProperty("key").GetString() == "block-2"); - var headline = block2.GetProperty("values").EnumerateArray().First().GetProperty("value").GetString(); - Assert.That(headline, Is.EqualTo("Updated Block 2")); - } -} From 24eff6063a8a6cb8be69f7b6ea63e0997d9ed515 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Mon, 2 Mar 2026 13:11:41 +0100 Subject: [PATCH 10/39] add escape support, more tests and cleanup --- .../Document/PatchDocumentRequestModel.cs | 2 +- .../ContentEditing/ContentPatchModel.cs | 2 +- .../ContentEditing/PatchOperationModel.cs | 2 +- .../Patching/PatchPathParser.cs | 35 ++- .../Serialization/JsonBlockValueConverter.cs | 2 +- .../Document/PatchDocumentControllerTests.cs | 228 +++++++++++++++++- .../Patching/PatchEngineTests.cs | 32 +++ .../Patching/PatchPathParserTests.cs | 63 +++++ 8 files changed, 344 insertions(+), 22 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs index 88e9d2d6cb8f..3900f8a7293e 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs @@ -3,7 +3,7 @@ namespace Umbraco.Cms.Api.Management.ViewModels.Document; /// -/// Request model for operation-based PATCH on documents using JSON Patch with JSONPath. +/// Request model for operation-based PATCH on documents using Umbraco's extended JSON Pointer path syntax. /// public class PatchDocumentRequestModel { diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs b/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs index c3fc039edc5e..23ab405c531e 100644 --- a/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs +++ b/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs @@ -1,7 +1,7 @@ namespace Umbraco.Cms.Core.Models.ContentEditing; /// -/// Model for operation-based partial content updates (PATCH with JSONPath). +/// Model for operation-based partial content updates using Umbraco's extended JSON Pointer path syntax. /// public class ContentPatchModel { diff --git a/src/Umbraco.Core/Models/ContentEditing/PatchOperationModel.cs b/src/Umbraco.Core/Models/ContentEditing/PatchOperationModel.cs index 3544cf047051..f3247ad887ae 100644 --- a/src/Umbraco.Core/Models/ContentEditing/PatchOperationModel.cs +++ b/src/Umbraco.Core/Models/ContentEditing/PatchOperationModel.cs @@ -11,7 +11,7 @@ public class PatchOperationModel public PatchOperationType Op { get; set; } /// - /// Gets or sets the JSONPath expression identifying the target location. + /// Gets or sets the patch path expression identifying the target location. /// public string Path { get; set; } = string.Empty; diff --git a/src/Umbraco.Core/PropertyEditors/Patching/PatchPathParser.cs b/src/Umbraco.Core/PropertyEditors/Patching/PatchPathParser.cs index fe0fe3c5682a..2a96bfef0769 100644 --- a/src/Umbraco.Core/PropertyEditors/Patching/PatchPathParser.cs +++ b/src/Umbraco.Core/PropertyEditors/Patching/PatchPathParser.cs @@ -1,16 +1,21 @@ namespace Umbraco.Cms.Core.PropertyEditors.Patching; /// -/// Parses Umbraco's custom patch path syntax into typed segments. +/// Parses Umbraco's patch path syntax into typed segments. /// -/// Syntax is based on JSON Pointer (RFC 6901) extended with array filter expressions: +/// The syntax is based on JSON Pointer (RFC 6901) +/// with a custom extension for array element filtering: /// -/// /property — access object property -/// [key=value,key2=null] — filter array element by matching properties -/// /0 — access array element by index -/// /- — append to end of array (Add operations only) +/// /property — access object property (RFC 6901 reference token) +/// /0 — access array element by index (RFC 6901 numeric token) +/// /- — append to end of array (RFC 6901 past-the-end token, Add operations only) +/// [key=value,key2=null] — filter array element by matching properties (Umbraco extension, not part of RFC 6901) /// /// +/// +/// RFC 6901 escape sequences are supported in property name tokens: +/// ~1 decodes to / and ~0 decodes to ~. +/// /// /// /variants[culture=en-US,segment=null]/name /// /values[alias=title,culture=en-US,segment=null]/value @@ -227,7 +232,23 @@ private static PatchPathSegment ParseToken(ReadOnlySpan token) return new IndexSegment(index); } - return new PropertySegment(token.ToString()); + var name = UnescapeRfc6901(token.ToString()); + return new PropertySegment(name); + } + + /// + /// Decodes RFC 6901 escape sequences in a reference token. + /// ~1 is decoded to / and ~0 is decoded to ~. + /// Per the RFC, ~1 must be decoded before ~0 to avoid double-decoding. + /// + private static string UnescapeRfc6901(string token) + { + if (!token.Contains('~')) + { + return token; + } + + return token.Replace("~1", "/").Replace("~0", "~"); } private static FilterSegment ParseFilter(ReadOnlySpan filterContent) diff --git a/src/Umbraco.Infrastructure/Serialization/JsonBlockValueConverter.cs b/src/Umbraco.Infrastructure/Serialization/JsonBlockValueConverter.cs index c763f9856961..df1f05f1b9ef 100644 --- a/src/Umbraco.Infrastructure/Serialization/JsonBlockValueConverter.cs +++ b/src/Umbraco.Infrastructure/Serialization/JsonBlockValueConverter.cs @@ -94,7 +94,7 @@ public override void Write(Utf8JsonWriter writer, BlockValue value, JsonSerializ Type layoutItemType = GetLayoutItemType(value.GetType()); - writer.WriteStartObject(nameof(BlockValue.Layout)); + writer.WriteStartObject(nameof(BlockValue.Layout).ToFirstLowerInvariant()); if (blockLayoutItems.Any()) { diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs index 9089b8f4617f..71f4773fe843 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs @@ -180,7 +180,7 @@ public async Task PatchDocument_SingleCulture_UpdatesOnlyThatCulture() // Act var httpContent = JsonContent.Create(patchModel); httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); - var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); // Assert Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); @@ -263,7 +263,7 @@ public async Task PatchDocument_MultipleCultures_UpdatesBoth() // Act var httpContent = JsonContent.Create(patchModel); httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); - var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); // Assert Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); @@ -329,7 +329,7 @@ public async Task PatchDocument_NonExistentCulture_ReturnsInvalidCulture() // Act var httpContent = JsonContent.Create(patchModel); httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); - var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); // Assert Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); @@ -393,7 +393,7 @@ public async Task PatchDocument_PropertyValue_UpdatesProperty() // Act var httpContent = JsonContent.Create(patchModel); httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); - var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); // Assert Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); @@ -477,7 +477,7 @@ public async Task PatchDocument_MultipleProperties_UpdatesAll() // Act var httpContent = JsonContent.Create(patchModel); httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); - var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); // Assert Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); @@ -553,7 +553,7 @@ public async Task PatchDocument_PropertyAndVariant_UpdatesBoth() // Act var httpContent = JsonContent.Create(patchModel); httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); - var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); // Assert Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); @@ -619,7 +619,7 @@ public async Task PatchDocument_InvalidPropertyAlias_ReturnsBadRequest() // Act var httpContent = JsonContent.Create(patchModel); httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); - var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); // Assert - Returns BadRequest (400) because path filter matches no elements Assert.AreEqual(HttpStatusCode.BadRequest, response.StatusCode); @@ -685,7 +685,7 @@ public async Task PatchDocument_SegmentProperty_UpdatesCorrectSegment() // Act var httpContent = JsonContent.Create(patchModel); httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); - var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); // Assert if (response.StatusCode != HttpStatusCode.OK) @@ -743,7 +743,7 @@ public async Task PatchDocument_DocumentInRecycleBin_AllowsPatch() // Act var httpContent = JsonContent.Create(patchModel); httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); - var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); // Assert - Umbraco allows patching documents in recycle bin (they can be edited before permanent deletion) Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); @@ -774,7 +774,7 @@ public async Task PatchDocument_NonExistentDocument_ReturnsNotFound() // Act var httpContent = JsonContent.Create(patchModel); httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); - var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{nonExistentKey}", httpContent); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{nonExistentKey}/patch", httpContent); // Assert Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode); @@ -941,7 +941,7 @@ public async Task PatchDocument_BlockList_SingleBlock_UpdatesSingleProperty() var httpContent = JsonContent.Create(patchModel); httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); - var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}", httpContent); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); // Assert if (response.StatusCode != HttpStatusCode.OK) @@ -987,4 +987,210 @@ public async Task PatchDocument_BlockList_SingleBlock_UpdatesSingleProperty() Assert.AreEqual("Block 3 Headline", block3Data.Values.FirstOrDefault(v => v.Alias == "headline")?.Value?.ToString()); Assert.AreEqual("Block 3 Description", block3Data.Values.FirstOrDefault(v => v.Alias == "description")?.Value?.ToString()); } + + [Test] + public async Task PatchDocument_BlockList_AddBlock_AppendsToExistingBlockList() + { + // Arrange - Authenticate as admin + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + + // Create element type for blocks + var elementType = new ContentTypeBuilder() + .WithAlias("heroBlock") + .WithName("Hero Block") + .AddPropertyType() + .WithAlias("headline") + .WithName("Headline") + .WithDataTypeId(Constants.DataTypes.Textbox) + .Done() + .AddPropertyType() + .WithAlias("description") + .WithName("Description") + .WithDataTypeId(Constants.DataTypes.Textarea) + .Done() + .Build(); + elementType.IsElement = true; + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + + // Create Block List data type + var propertyEditorCollection = GetRequiredService(); + var configurationEditorJsonSerializer = GetRequiredService(); + + var blockListDataType = new UmbracoDataType( + propertyEditorCollection[Constants.PropertyEditors.Aliases.BlockList], + configurationEditorJsonSerializer) + { + ConfigurationData = new Dictionary + { + { + "blocks", + new[] + { + new { contentElementTypeKey = elementType.Key } + } + } + }, + Name = "My Block List", + DatabaseType = ValueStorageType.Ntext, + ParentId = Constants.System.Root, + CreateDate = DateTime.UtcNow + }; + + var dataTypeService = GetRequiredService(); + await dataTypeService.CreateAsync(blockListDataType, Constants.Security.SuperUserKey); + + // Create content type with Block List property + var contentType = new ContentTypeBuilder() + .WithAlias("blockListPage") + .WithName("Block List Page") + .AddPropertyType() + .WithAlias("contentBlocks") + .WithName("Content Blocks") + .WithDataTypeId(blockListDataType.Id) + .Done() + .Build(); + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // Create two existing blocks + var block1Key = Guid.NewGuid(); + var block2Key = Guid.NewGuid(); + + var jsonSerializer = GetRequiredService(); + var blockListValue = new BlockListValue + { + Layout = new Dictionary> + { + { + Constants.PropertyEditors.Aliases.BlockList, + new IBlockLayoutItem[] + { + new BlockListLayoutItem { ContentKey = block1Key }, + new BlockListLayoutItem { ContentKey = block2Key } + } + } + }, + ContentData = new List + { + new BlockItemData + { + Key = block1Key, + ContentTypeKey = elementType.Key, + ContentTypeAlias = elementType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "headline", Value = "Block 1 Headline" }, + new BlockPropertyValue { Alias = "description", Value = "Block 1 Description" } + } + }, + new BlockItemData + { + Key = block2Key, + ContentTypeKey = elementType.Key, + ContentTypeAlias = elementType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "headline", Value = "Block 2 Headline" }, + new BlockPropertyValue { Alias = "description", Value = "Block 2 Description" } + } + } + }, + SettingsData = new List(), + Expose = new List() + }; + + var blockListJson = jsonSerializer.Serialize(blockListValue); + + // Create document with Block List containing 2 blocks + var content = new ContentBuilder() + .WithContentType(contentType) + .WithName("My Blocks Document") + .WithCreatorId(Constants.Security.SuperUserId) + .Build(); + + content.SetValue("contentBlocks", blockListJson); + ContentService.Save(content); + var documentKey = content.Key; + + // Act - Add a new block to the block list using two add operations: + // 1. Append block data to contentData array + // 2. Append layout item to layout array (so the block is rendered) + var newBlockKey = Guid.NewGuid(); + + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "add", + Path = "/values[alias=contentBlocks,culture=null,segment=null]/value/contentData/-", + Value = new + { + key = newBlockKey, + contentTypeKey = elementType.Key, + contentTypeAlias = elementType.Alias, + values = new[] + { + new { alias = "headline", value = "New Block Headline" }, + new { alias = "description", value = "New Block Description" } + } + } + }, + new PatchOperationRequestModel + { + Op = "add", + Path = $"/values[alias=contentBlocks,culture=null,segment=null]/value/layout/Umbraco.BlockList/-", + Value = new { contentKey = newBlockKey } + } + } + }; + + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{documentKey}/patch", httpContent); + + // Assert + if (response.StatusCode != HttpStatusCode.OK) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Error response: {response.StatusCode}"); + Console.WriteLine($"Error body: {errorContent}"); + } + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify the block list now has 3 blocks + var updatedContent = ContentService.GetById(documentKey); + Assert.IsNotNull(updatedContent); + + var updatedBlockListJson = updatedContent.GetValue("contentBlocks"); + Assert.IsNotNull(updatedBlockListJson); + + var updatedBlockListValue = jsonSerializer.Deserialize(updatedBlockListJson); + Assert.IsNotNull(updatedBlockListValue); + + // Verify contentData has 3 blocks + Assert.AreEqual(3, updatedBlockListValue.ContentData.Count); + + // Verify the new block was added with correct values + var newBlockData = updatedBlockListValue.ContentData.FirstOrDefault(b => b.Key == newBlockKey); + Assert.IsNotNull(newBlockData); + Assert.AreEqual("New Block Headline", newBlockData.Values.FirstOrDefault(v => v.Alias == "headline")?.Value?.ToString()); + Assert.AreEqual("New Block Description", newBlockData.Values.FirstOrDefault(v => v.Alias == "description")?.Value?.ToString()); + + // Verify original blocks were NOT changed + var block1Data = updatedBlockListValue.ContentData.FirstOrDefault(b => b.Key == block1Key); + Assert.IsNotNull(block1Data); + Assert.AreEqual("Block 1 Headline", block1Data.Values.FirstOrDefault(v => v.Alias == "headline")?.Value?.ToString()); + Assert.AreEqual("Block 1 Description", block1Data.Values.FirstOrDefault(v => v.Alias == "description")?.Value?.ToString()); + + var block2Data = updatedBlockListValue.ContentData.FirstOrDefault(b => b.Key == block2Key); + Assert.IsNotNull(block2Data); + Assert.AreEqual("Block 2 Headline", block2Data.Values.FirstOrDefault(v => v.Alias == "headline")?.Value?.ToString()); + Assert.AreEqual("Block 2 Description", block2Data.Values.FirstOrDefault(v => v.Alias == "description")?.Value?.ToString()); + + // Verify layout was also updated with the new block + var layoutItems = updatedBlockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].ToList(); + Assert.AreEqual(3, layoutItems.Count); + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchEngineTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchEngineTests.cs index 9de267fb9069..a1a7e57ed434 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchEngineTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchEngineTests.cs @@ -469,4 +469,36 @@ public void Replace_MultipleFilterConditions_MatchesCorrectElement() Assert.That(values[1].GetProperty("value").GetString(), Is.EqualTo("200")); Assert.That(values[2].GetProperty("value").GetString(), Is.EqualTo("1000")); } + + // RFC 6901 escape sequence tests + + [Test] + public void Replace_PropertyNameContainingSlash_RequiresEscaping() + { + var json = """ + { + "a/b": "original" + } + """; + + var result = PatchEngine.ApplyOperation(json, PatchOperationType.Replace, "/a~1b", "updated"); + + var doc = JsonDocument.Parse(result); + Assert.That(doc.RootElement.GetProperty("a/b").GetString(), Is.EqualTo("updated")); + } + + [Test] + public void Replace_PropertyNameContainingTilde_RequiresEscaping() + { + var json = """ + { + "a~b": "original" + } + """; + + var result = PatchEngine.ApplyOperation(json, PatchOperationType.Replace, "/a~0b", "updated"); + + var doc = JsonDocument.Parse(result); + Assert.That(doc.RootElement.GetProperty("a~b").GetString(), Is.EqualTo("updated")); + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchPathParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchPathParserTests.cs index eb21e83625d3..6d5e6a29fea6 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchPathParserTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchPathParserTests.cs @@ -245,4 +245,67 @@ public void TargetsInvariantCulture_InvalidPath_ReturnsFalse() { Assert.That(PatchPathParser.TargetsInvariantCulture(string.Empty), Is.False); } + + // RFC 6901 escape sequence tests + + [Test] + public void Parse_Tilde1EscapeSequence_DecodesToSlash() + { + var segments = PatchPathParser.Parse("/a~1b"); + + Assert.That(segments, Has.Length.EqualTo(1)); + Assert.That(segments[0], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("a/b")); + } + + [Test] + public void Parse_Tilde0EscapeSequence_DecodesToTilde() + { + var segments = PatchPathParser.Parse("/a~0b"); + + Assert.That(segments, Has.Length.EqualTo(1)); + Assert.That(segments[0], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("a~b")); + } + + [Test] + public void Parse_Tilde01Combined_DecodesCorrectly() + { + // ~01 should decode to ~1 (not /), because ~0 decodes to ~ and the trailing 1 stays. + // The implementation decodes ~1 first (to /), then ~0 (to ~). + // So ~01 → after ~1 pass: ~01 (no match) → after ~0 pass: ~1? No... + // Actually: "~01" → Replace("~1", "/") has no match → Replace("~0", "~") → "~1" + var segments = PatchPathParser.Parse("/~01"); + + Assert.That(segments, Has.Length.EqualTo(1)); + Assert.That(segments[0], Is.InstanceOf()); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("~1")); + } + + [Test] + public void Parse_NoEscapeSequences_PropertyNameUnchanged() + { + var segments = PatchPathParser.Parse("/normalProperty"); + + Assert.That(segments, Has.Length.EqualTo(1)); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("normalProperty")); + } + + [Test] + public void Parse_EscapeSequenceInMultiSegmentPath_DecodesOnlyAffectedSegment() + { + var segments = PatchPathParser.Parse("/foo/bar~1baz/qux"); + + Assert.That(segments, Has.Length.EqualTo(3)); + Assert.That(((PropertySegment)segments[0]).Name, Is.EqualTo("foo")); + Assert.That(((PropertySegment)segments[1]).Name, Is.EqualTo("bar/baz")); + Assert.That(((PropertySegment)segments[2]).Name, Is.EqualTo("qux")); + } + + [Test] + public void IsValid_PathWithEscapeSequence_ReturnsTrue() + { + Assert.That(PatchPathParser.IsValid("/foo~1bar"), Is.True); + Assert.That(PatchPathParser.IsValid("/foo~0bar"), Is.True); + } } From f6159801f05ba689c66f3336ebec4c1f22bcc01c Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Mon, 2 Mar 2026 13:13:43 +0100 Subject: [PATCH 11/39] remove unnecesary using --- .../PropertyEditors/ColorPickerPropertyValueEditorTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorPickerPropertyValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorPickerPropertyValueEditorTests.cs index 17b49f6b13cf..f17daf2675df 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorPickerPropertyValueEditorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/ColorPickerPropertyValueEditorTests.cs @@ -1,5 +1,4 @@ using System.Globalization; -using Humanizer; using System.Text.Json.Nodes; using Moq; using NUnit.Framework; From 1a03c1219ec7ed52ca2141ab342c0f86f7bb3820 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 4 Mar 2026 13:04:22 +0100 Subject: [PATCH 12/39] Cleanup --- .../Document/PatchDocumentController.cs | 8 +- .../JsonBuilderExtensions.cs | 8 - .../ContentPatchingOperationStatus.cs | 2 +- .../Patchers/DocumentPatcher.cs | 2 - .../ContentEditing/ContentPatchModel.cs | 4 - .../ContentEditing/ContentPatchResult.cs | 24 - .../JsonPath/JsonPathCultureExtractor.cs | 94 --- .../PropertyEditors/Patching/PatchEngine.cs | 1 + .../Document/PatchDocumentControllerTests.cs | 637 +++++++++++++++++- .../JsonPath/JsonPathCultureExtractorTests.cs | 193 ------ 10 files changed, 637 insertions(+), 336 deletions(-) delete mode 100644 src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs delete mode 100644 src/Umbraco.Core/Models/ContentEditing/ContentPatchResult.cs delete mode 100644 src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathCultureExtractor.cs delete mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathCultureExtractorTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs index e0b40b2eba73..4bf78341a590 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs @@ -21,22 +21,19 @@ public class PatchDocumentController : PatchDocumentControllerBase private readonly DocumentPatcher _documentPatcher; private readonly IDocumentEditingPresentationFactory _presentationFactory; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; - private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; public PatchDocumentController( IAuthorizationService authorizationService, IContentEditingService contentEditingService, DocumentPatcher documentPatcher, IDocumentEditingPresentationFactory presentationFactory, - IBackOfficeSecurityAccessor backOfficeSecurityAccessor, - IDocumentEditingPresentationFactory documentEditingPresentationFactory) + IBackOfficeSecurityAccessor backOfficeSecurityAccessor) : base(authorizationService) { _contentEditingService = contentEditingService; _documentPatcher = documentPatcher; _presentationFactory = presentationFactory; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; - _documentEditingPresentationFactory = documentEditingPresentationFactory; } [HttpPatch("{id:guid}/patch")] @@ -53,7 +50,6 @@ public async Task Patch( => await HandleRequest(id, requestModel, async () => { // Map request model to domain model - // todo: dont use intermitent model as patching happens in the api layer => make patcher work with PatchDocumentRequestModel directly ContentPatchModel patchModel = _presentationFactory.MapPatchModel(requestModel); // Apply PATCH operations to create an update request model @@ -65,7 +61,7 @@ public async Task Patch( return ContentPatchingOperationStatusResult(patchResult.Status); } - ContentUpdateModel contentUpdateModel = _documentEditingPresentationFactory.MapUpdateModel(patchResult.Result); + ContentUpdateModel contentUpdateModel = _presentationFactory.MapUpdateModel(patchResult.Result); // Use the standard update method to save the patched content Attempt updateResult = diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs deleted file mode 100644 index 069d4c5f974d..000000000000 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Umbraco.Cms.Core.DependencyInjection; - -namespace Umbraco.Cms.Api.Management.DependencyInjection; - -public static class JsonBuilderExtensions -{ - internal static IUmbracoBuilder AddJson(this IUmbracoBuilder builder) => builder; -} diff --git a/src/Umbraco.Cms.Api.Management/OperationStatus/ContentPatchingOperationStatus.cs b/src/Umbraco.Cms.Api.Management/OperationStatus/ContentPatchingOperationStatus.cs index b37ab9b5dee8..f6b26cdc50af 100644 --- a/src/Umbraco.Cms.Api.Management/OperationStatus/ContentPatchingOperationStatus.cs +++ b/src/Umbraco.Cms.Api.Management/OperationStatus/ContentPatchingOperationStatus.cs @@ -12,7 +12,7 @@ public enum ContentPatchingOperationStatus Success, /// - /// One or more PATCH operations were invalid (invalid JSONPath syntax, unsupported operation type, missing required value). + /// One or more PATCH operations were invalid (invalid path syntax, unsupported operation type, missing required value). /// InvalidOperation, diff --git a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs index 78a3e3febf9e..b5451a24a042 100644 --- a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs +++ b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs @@ -21,8 +21,6 @@ public class DocumentPatcher public DocumentPatcher( IContentEditingService contentEditingService, - IContentTypeService contentTypeService, - ILanguageService languageService, IJsonSerializer jsonSerializer, IDocumentEditingPresentationFactory documentEditingPresentationFactory) { diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs b/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs index 23ab405c531e..3ffd5124bb64 100644 --- a/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs +++ b/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs @@ -21,8 +21,4 @@ public class ContentPatchModel /// public IEnumerable AffectedSegments { get; set; } = Array.Empty(); - /// - /// Property aliases explicitly affected by this patch (extracted from operation paths). - /// - public IEnumerable AffectedProperties { get; set; } = Array.Empty(); } diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentPatchResult.cs b/src/Umbraco.Core/Models/ContentEditing/ContentPatchResult.cs deleted file mode 100644 index 0356a44bd534..000000000000 --- a/src/Umbraco.Core/Models/ContentEditing/ContentPatchResult.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Umbraco.Cms.Core.Models.ContentEditing; - -/// -/// Result of a content patch operation. -/// -public class ContentPatchResult -{ - public required IContent Content { get; init; } - - /// - /// Cultures that were modified by this patch. - /// - public IEnumerable AffectedCultures { get; init; } = Array.Empty(); - - /// - /// Property aliases that were modified by this patch. - /// - public IEnumerable AffectedProperties { get; init; } = Array.Empty(); - - /// - /// Validation result for properties. - /// - public ContentValidationResult ValidationResult { get; init; } = new(); -} diff --git a/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathCultureExtractor.cs b/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathCultureExtractor.cs deleted file mode 100644 index cda50531e870..000000000000 --- a/src/Umbraco.Core/PropertyEditors/JsonPath/JsonPathCultureExtractor.cs +++ /dev/null @@ -1,94 +0,0 @@ -using Umbraco.Cms.Core.PropertyEditors.Patching; - -namespace Umbraco.Cms.Core.PropertyEditors.JsonPath; - -/// -/// Extracts culture and segment information from patch path expressions for authorization purposes. -/// Delegates to for path parsing. -/// -public class JsonPathCultureExtractor -{ - /// - /// Extracts all unique cultures referenced in a path expression. - /// - /// The patch path expression to parse. - /// A set of culture codes (e.g., "en-US", "da-DK"), or empty set if no cultures found. - public ISet ExtractCultures(string pathExpression) - => PatchPathParser.ExtractCultures(pathExpression); - - /// - /// Extracts all unique segments referenced in a path expression. - /// - /// The patch path expression to parse. - /// A set of segment names, or empty set if no segments found. - public ISet ExtractSegments(string pathExpression) - => PatchPathParser.ExtractSegments(pathExpression); - - /// - /// Checks if a path expression explicitly targets invariant content (culture=null). - /// - /// The path expression to parse. - /// True if the expression targets invariant culture, false otherwise. - public bool ContainsInvariantCultureFilter(string pathExpression) - => PatchPathParser.TargetsInvariantCulture(pathExpression); - - /// - /// Checks if a path expression explicitly targets null segment. - /// - /// The path expression to parse. - /// True if the expression targets null segment, false otherwise. - public bool ContainsNullSegmentFilter(string pathExpression) - { - if (!PatchPathParser.IsValid(pathExpression)) - { - return false; - } - - PatchPathSegment[] segments = PatchPathParser.Parse(pathExpression); - foreach (PatchPathSegment segment in segments) - { - if (segment is FilterSegment filter) - { - foreach (FilterCondition condition in filter.Conditions) - { - if (string.Equals(condition.Key, "segment", StringComparison.OrdinalIgnoreCase) - && condition.Value is null) - { - return true; - } - } - } - } - - return false; - } - - /// - /// Extracts all cultures from a collection of path expressions. - /// - /// Collection of path expressions. - /// A set of all unique cultures across all expressions. - public ISet ExtractCulturesFromOperations(IEnumerable pathExpressions) - { - var allCultures = new HashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var path in pathExpressions) - { - ISet cultures = ExtractCultures(path); - foreach (var culture in cultures) - { - allCultures.Add(culture); - } - } - - return allCultures; - } - - /// - /// Checks if any of the path expressions target invariant content (null culture). - /// - /// Collection of path expressions. - /// True if any expression contains invariant culture filter. - public bool AnyOperationTargetsInvariantCulture(IEnumerable pathExpressions) - => pathExpressions.Any(ContainsInvariantCultureFilter); -} diff --git a/src/Umbraco.Core/PropertyEditors/Patching/PatchEngine.cs b/src/Umbraco.Core/PropertyEditors/Patching/PatchEngine.cs index fb7125bac06b..282c94f71ab6 100644 --- a/src/Umbraco.Core/PropertyEditors/Patching/PatchEngine.cs +++ b/src/Umbraco.Core/PropertyEditors/Patching/PatchEngine.cs @@ -17,6 +17,7 @@ public static class PatchEngine /// The patch path expression. /// The value to set (required for Replace and Add operations). /// The modified JSON string. + /// Thrown when is null or whitespace. /// Thrown when the operation cannot be applied. /// Thrown when the path syntax is invalid. public static string ApplyOperation(string json, PatchOperationType op, string path, object? value) diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs index 71f4773fe843..790317d1bd4b 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/Document/PatchDocumentControllerTests.cs @@ -1,6 +1,7 @@ using System.Linq.Expressions; using System.Net; using System.Net.Http.Json; +using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using Umbraco.Cms.Api.Management.Controllers.Document; using Umbraco.Cms.Api.Management.ViewModels.Document; @@ -785,10 +786,11 @@ public async Task PatchDocument_BlockList_SingleBlock_UpdatesSingleProperty() { // Arrange - Authenticate as admin await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + var suffix = Guid.NewGuid().ToString("N")[..8]; // Create element type for blocks var elementType = new ContentTypeBuilder() - .WithAlias("heroBlock") + .WithAlias($"heroBlock{suffix}") .WithName("Hero Block") .AddPropertyType() .WithAlias("headline") @@ -833,7 +835,7 @@ public async Task PatchDocument_BlockList_SingleBlock_UpdatesSingleProperty() // Create content type with Block List property var contentType = new ContentTypeBuilder() - .WithAlias("blockListPage") + .WithAlias($"blockListPage{suffix}") .WithName("Block List Page") .AddPropertyType() .WithAlias("contentBlocks") @@ -993,10 +995,11 @@ public async Task PatchDocument_BlockList_AddBlock_AppendsToExistingBlockList() { // Arrange - Authenticate as admin await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + var suffix = Guid.NewGuid().ToString("N")[..8]; // Create element type for blocks var elementType = new ContentTypeBuilder() - .WithAlias("heroBlock") + .WithAlias($"heroBlock{suffix}") .WithName("Hero Block") .AddPropertyType() .WithAlias("headline") @@ -1041,7 +1044,7 @@ public async Task PatchDocument_BlockList_AddBlock_AppendsToExistingBlockList() // Create content type with Block List property var contentType = new ContentTypeBuilder() - .WithAlias("blockListPage") + .WithAlias($"blockListPage{suffix}") .WithName("Block List Page") .AddPropertyType() .WithAlias("contentBlocks") @@ -1193,4 +1196,630 @@ public async Task PatchDocument_BlockList_AddBlock_AppendsToExistingBlockList() var layoutItems = updatedBlockListValue.Layout[Constants.PropertyEditors.Aliases.BlockList].ToList(); Assert.AreEqual(3, layoutItems.Count); } + + // ── Deeply nested block editor tests ────────────────────────────────────── + // + // Structure: Document → RTE → BlockGrid (with areas) → BlockList → textBlock + // + // These tests prove that PatchEngine can navigate through multiple layers of + // recursively-expanded block editor values (RTE > Block Grid > Block List). + + /// + /// Builds the full deeply-nested block editor structure used by the deep nesting tests. + /// Returns all keys and identifiers needed to construct patch paths. + /// + private async Task CreateDeeplyNestedBlockDocument() + { + var jsonSerializer = GetRequiredService(); + var propertyEditorCollection = GetRequiredService(); + var configEditorJsonSerializer = GetRequiredService(); + var dataTypeService = GetRequiredService(); + + // ── Languages ── + var langEnUs = new LanguageBuilder().WithCultureInfo("en-US").WithIsDefault(true).Build(); + await LanguageService.CreateAsync(langEnUs, Constants.Security.SuperUserKey); + var langDaDk = new LanguageBuilder().WithCultureInfo("da-DK").Build(); + await LanguageService.CreateAsync(langDaDk, Constants.Security.SuperUserKey); + + // ── Element type: textBlock (culture-variant, has "text" property) ── + var suffix = Guid.NewGuid().ToString("N")[..8]; + var textBlockType = new ContentTypeBuilder() + .WithAlias($"textBlock{suffix}") + .WithName("Text Block") + .AddPropertyType() + .WithAlias("text") + .WithName("Text") + .WithDataTypeId(Constants.DataTypes.Textbox) + .Done() + .Build(); + textBlockType.IsElement = true; + textBlockType.Variations = ContentVariation.Culture; + foreach (var pt in textBlockType.PropertyTypes) + { + pt.Variations = ContentVariation.Culture; + } + + await ContentTypeService.CreateAsync(textBlockType, Constants.Security.SuperUserKey); + + // ── Element type: listContainerBlock (has "blockList" property) ── + // First create the Block List data type configured with textBlock + var blockListDataType = new UmbracoDataType( + propertyEditorCollection[Constants.PropertyEditors.Aliases.BlockList], + configEditorJsonSerializer) + { + ConfigurationData = new Dictionary + { + { "blocks", new[] { new { contentElementTypeKey = textBlockType.Key } } } + }, + Name = "Test Block List", + DatabaseType = ValueStorageType.Ntext, + ParentId = Constants.System.Root, + CreateDate = DateTime.UtcNow + }; + await dataTypeService.CreateAsync(blockListDataType, Constants.Security.SuperUserKey); + + var listContainerType = new ContentTypeBuilder() + .WithAlias($"listContainerBlock{suffix}") + .WithName("List Container Block") + .AddPropertyType() + .WithAlias("blockList") + .WithName("Block List") + .WithDataTypeId(blockListDataType.Id) + .Done() + .Build(); + listContainerType.IsElement = true; + await ContentTypeService.CreateAsync(listContainerType, Constants.Security.SuperUserKey); + + // ── Element type: areaBlock (empty container, holds areas) ── + var areaBlockType = new ContentTypeBuilder() + .WithAlias($"areaBlock{suffix}") + .WithName("Area Block") + .Build(); + areaBlockType.IsElement = true; + await ContentTypeService.CreateAsync(areaBlockType, Constants.Security.SuperUserKey); + + // ── Block Grid data type ── + var areaKey = Guid.NewGuid(); + var blockGridDataType = new UmbracoDataType( + propertyEditorCollection[Constants.PropertyEditors.Aliases.BlockGrid], + configEditorJsonSerializer) + { + ConfigurationData = new Dictionary + { + { + "blocks", new object[] + { + new + { + contentElementTypeKey = areaBlockType.Key, + allowAtRoot = true, + allowInAreas = false, + areaGridColumns = 12, + areas = new[] + { + new + { + key = areaKey, + alias = "content", + columnSpan = 12, + rowSpan = 1, + minAllowed = 0, + maxAllowed = 10 + } + } + }, + new + { + contentElementTypeKey = listContainerType.Key, + allowAtRoot = false, + allowInAreas = true, + areas = Array.Empty() + } + } + } + }, + Name = "Test Block Grid", + DatabaseType = ValueStorageType.Ntext, + ParentId = Constants.System.Root, + CreateDate = DateTime.UtcNow + }; + await dataTypeService.CreateAsync(blockGridDataType, Constants.Security.SuperUserKey); + + // ── Element type: gridContainerBlock (has "blockGrid" property) ── + var gridContainerType = new ContentTypeBuilder() + .WithAlias($"gridContainerBlock{suffix}") + .WithName("Grid Container Block") + .AddPropertyType() + .WithAlias("blockGrid") + .WithName("Block Grid") + .WithDataTypeId(blockGridDataType.Id) + .Done() + .Build(); + gridContainerType.IsElement = true; + await ContentTypeService.CreateAsync(gridContainerType, Constants.Security.SuperUserKey); + + // ── RTE data type ── + var rteDataType = new UmbracoDataType( + propertyEditorCollection[Constants.PropertyEditors.Aliases.RichText], + configEditorJsonSerializer) + { + ConfigurationData = new Dictionary + { + { "blocks", new[] { new { contentElementTypeKey = gridContainerType.Key } } } + }, + Name = "Test RTE", + DatabaseType = ValueStorageType.Ntext, + ParentId = Constants.System.Root, + CreateDate = DateTime.UtcNow + }; + await dataTypeService.CreateAsync(rteDataType, Constants.Security.SuperUserKey); + + // ── Document content type ── + var contentType = new ContentTypeBuilder() + .WithAlias($"deepNestedPage{suffix}") + .WithName("Deep Nested Page") + .AddPropertyType() + .WithAlias("rte") + .WithName("Rich Text") + .WithDataTypeId(rteDataType.Id) + .Done() + .Build(); + contentType.AllowedAsRoot = true; + contentType.Variations = ContentVariation.Culture; + foreach (var pt in contentType.PropertyTypes) + { + pt.Variations = ContentVariation.Culture; + } + + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + // ── Build nested block values (inside-out) ── + var textBlock1Key = Guid.NewGuid(); + var textBlock2Key = Guid.NewGuid(); + var listContainerKey = Guid.NewGuid(); + var areaBlockKey = Guid.NewGuid(); + var gridContainerKey = Guid.NewGuid(); + + // Layer 1: Block List value with 2 text blocks (culture-variant) + var blockListValue = new BlockListValue + { + Layout = new Dictionary> + { + { + Constants.PropertyEditors.Aliases.BlockList, + new IBlockLayoutItem[] + { + new BlockListLayoutItem { ContentKey = textBlock1Key }, + new BlockListLayoutItem { ContentKey = textBlock2Key } + } + } + }, + ContentData = new List + { + new BlockItemData + { + Key = textBlock1Key, + ContentTypeKey = textBlockType.Key, + ContentTypeAlias = textBlockType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "text", Culture = "en-US", Value = "original en" }, + new BlockPropertyValue { Alias = "text", Culture = "da-DK", Value = "original da" } + } + }, + new BlockItemData + { + Key = textBlock2Key, + ContentTypeKey = textBlockType.Key, + ContentTypeAlias = textBlockType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "text", Culture = "en-US", Value = "second block en" }, + new BlockPropertyValue { Alias = "text", Culture = "da-DK", Value = "second block da" } + } + } + }, + SettingsData = new List(), + Expose = new List + { + new BlockItemVariation(textBlock1Key, "en-US", null), + new BlockItemVariation(textBlock1Key, "da-DK", null), + new BlockItemVariation(textBlock2Key, "en-US", null), + new BlockItemVariation(textBlock2Key, "da-DK", null) + } + }; + var blockListJson = jsonSerializer.Serialize(blockListValue); + + // Layer 2: Block Grid value with areaBlock (root) + listContainerBlock (in area) + var blockGridValue = new BlockGridValue(new[] + { + new BlockGridLayoutItem(areaBlockKey) + { + ColumnSpan = 12, + RowSpan = 1, + Areas = new[] + { + new BlockGridLayoutAreaItem(areaKey) + { + Items = new[] + { + new BlockGridLayoutItem(listContainerKey) + { + ColumnSpan = 12, + RowSpan = 1, + Areas = Array.Empty() + } + } + } + } + } + }) + { + ContentData = new List + { + new BlockItemData + { + Key = areaBlockKey, + ContentTypeKey = areaBlockType.Key, + ContentTypeAlias = areaBlockType.Alias, + Values = new List() + }, + new BlockItemData + { + Key = listContainerKey, + ContentTypeKey = listContainerType.Key, + ContentTypeAlias = listContainerType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "blockList", Value = blockListJson } + } + } + }, + SettingsData = new List(), + Expose = new List + { + new BlockItemVariation(areaBlockKey, null, null), + new BlockItemVariation(listContainerKey, null, null) + } + }; + var blockGridJson = jsonSerializer.Serialize(blockGridValue); + + // Layer 3: RTE value with gridContainerBlock + var rteBlockValue = new RichTextBlockValue(new[] + { + new RichTextBlockLayoutItem(gridContainerKey) + }) + { + ContentData = new List + { + new BlockItemData + { + Key = gridContainerKey, + ContentTypeKey = gridContainerType.Key, + ContentTypeAlias = gridContainerType.Alias, + Values = new List + { + new BlockPropertyValue { Alias = "blockGrid", Value = blockGridJson } + } + } + }, + SettingsData = new List(), + Expose = new List + { + new BlockItemVariation(gridContainerKey, null, null) + } + }; + + var rteEditorValue = new RichTextEditorValue + { + Markup = "

Hello

", + Blocks = rteBlockValue + }; + var rteJson = RichTextPropertyEditorHelper.SerializeRichTextEditorValue(rteEditorValue, jsonSerializer); + + // ── Create document ── + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + Variants = new List + { + new() { Name = "English Page", Culture = "en-US" }, + new() { Name = "Danish Page", Culture = "da-DK" }, + }, + Properties = new List + { + new() { Alias = "rte", Value = rteJson, Culture = "en-US" }, + new() { Alias = "rte", Value = rteJson, Culture = "da-DK" } + } + }; + var createResult = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + Assert.IsTrue(createResult.Success); + + return new DeepNestedSetup + { + DocumentKey = createResult.Result.Content!.Key, + GridContainerKey = gridContainerKey, + ListContainerKey = listContainerKey, + TextBlock1Key = textBlock1Key, + TextBlock2Key = textBlock2Key, + TextBlockTypeKey = textBlockType.Key, + JsonSerializer = jsonSerializer + }; + } + + private record DeepNestedSetup + { + public Guid DocumentKey { get; init; } + public Guid GridContainerKey { get; init; } + public Guid ListContainerKey { get; init; } + public Guid TextBlock1Key { get; init; } + public Guid TextBlock2Key { get; init; } + public Guid TextBlockTypeKey { get; init; } + public IJsonSerializer JsonSerializer { get; init; } = null!; + + /// + /// Builds the common path prefix to reach the nested block list value. + /// + public string BlockListPathPrefix(string culture) => + $"/values[alias=rte,culture={culture},segment=null]/value/blocks" + + $"/contentData[key={GridContainerKey}]/values[alias=blockGrid]/value" + + $"/contentData[key={ListContainerKey}]/values[alias=blockList]/value"; + } + + [Test] + public async Task PatchDocument_DeeplyNestedBlocks_ReplacesPropertyAtDeepestLevel() + { + // Arrange + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + var setup = await CreateDeeplyNestedBlockDocument(); + + // Path to the first text block's text property for en-US culture + var path = setup.BlockListPathPrefix("en-US") + + $"/contentData[key={setup.TextBlock1Key}]/values[alias=text,culture=en-US]/value"; + + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "replace", + Path = path, + Value = "updated deep value" + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{setup.DocumentKey}/patch", httpContent); + + // Assert + if (response.StatusCode != HttpStatusCode.OK) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Error response: {response.StatusCode}"); + Console.WriteLine($"Error body: {errorContent}"); + } + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify the deeply nested value was updated for en-US + var updatedContent = await ContentEditingService.GetAsync(setup.DocumentKey); + Assert.IsNotNull(updatedContent); + + var rteValue = updatedContent.GetValue("rte", "en-US"); + Assert.IsNotNull(rteValue); + + // Parse through the nested structure to verify the patched value + RichTextPropertyEditorHelper.TryParseRichTextEditorValue(rteValue, setup.JsonSerializer, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, out var parsedRte); + Assert.IsNotNull(parsedRte?.Blocks); + + var gridContainerData = parsedRte!.Blocks!.ContentData.FirstOrDefault(b => b.Key == setup.GridContainerKey); + Assert.IsNotNull(gridContainerData); + + var blockGridRaw = gridContainerData!.Values.FirstOrDefault(v => v.Alias == "blockGrid")?.Value?.ToString(); + Assert.IsNotNull(blockGridRaw); + + var blockGridVal = setup.JsonSerializer.Deserialize(blockGridRaw!); + Assert.IsNotNull(blockGridVal); + + var listContainerData = blockGridVal!.ContentData.FirstOrDefault(b => b.Key == setup.ListContainerKey); + Assert.IsNotNull(listContainerData); + + var blockListRaw = listContainerData!.Values.FirstOrDefault(v => v.Alias == "blockList")?.Value?.ToString(); + Assert.IsNotNull(blockListRaw); + + var blockListVal = setup.JsonSerializer.Deserialize(blockListRaw!); + Assert.IsNotNull(blockListVal); + + // Verify the patched text block + var textBlock1 = blockListVal!.ContentData.FirstOrDefault(b => b.Key == setup.TextBlock1Key); + Assert.IsNotNull(textBlock1); + Assert.AreEqual("updated deep value", + textBlock1!.Values.FirstOrDefault(v => v.Alias == "text" && v.Culture == "en-US")?.Value?.ToString()); + + // Verify da-DK text was NOT changed + Assert.AreEqual("original da", + textBlock1.Values.FirstOrDefault(v => v.Alias == "text" && v.Culture == "da-DK")?.Value?.ToString()); + + // Verify the second text block was NOT changed + var textBlock2 = blockListVal.ContentData.FirstOrDefault(b => b.Key == setup.TextBlock2Key); + Assert.IsNotNull(textBlock2); + Assert.AreEqual("second block en", + textBlock2!.Values.FirstOrDefault(v => v.Alias == "text" && v.Culture == "en-US")?.Value?.ToString()); + } + + [Test] + public async Task PatchDocument_DeeplyNestedBlocks_AddsBlockToNestedBlockList() + { + // Arrange + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + var setup = await CreateDeeplyNestedBlockDocument(); + + var prefix = setup.BlockListPathPrefix("en-US"); + var newBlockKey = Guid.NewGuid(); + + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "add", + Path = $"{prefix}/contentData/-", + Value = new + { + key = newBlockKey, + contentTypeKey = setup.TextBlockTypeKey, + values = new[] + { + new { alias = "text", culture = "en-US", value = "new block en" }, + new { alias = "text", culture = "da-DK", value = "new block da" } + } + } + }, + new PatchOperationRequestModel + { + Op = "add", + Path = $"{prefix}/layout/Umbraco.BlockList/-", + Value = new { contentKey = newBlockKey } + }, + new PatchOperationRequestModel + { + Op = "add", + Path = $"{prefix}/expose/-", + Value = new { contentKey = newBlockKey, culture = "en-US" } + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{setup.DocumentKey}/patch", httpContent); + + // Assert + if (response.StatusCode != HttpStatusCode.OK) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Error response: {response.StatusCode}"); + Console.WriteLine($"Error body: {errorContent}"); + } + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Parse through the nested structure to the block list + var updatedContent = await ContentEditingService.GetAsync(setup.DocumentKey); + Assert.IsNotNull(updatedContent); + var rteValue = updatedContent.GetValue("rte", "en-US"); + RichTextPropertyEditorHelper.TryParseRichTextEditorValue(rteValue, setup.JsonSerializer, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, out var parsedRte); + var gridContainerData = parsedRte!.Blocks!.ContentData.First(b => b.Key == setup.GridContainerKey); + var blockGridVal = setup.JsonSerializer.Deserialize(gridContainerData.Values.First(v => v.Alias == "blockGrid").Value!.ToString()!); + var listContainerData = blockGridVal!.ContentData.First(b => b.Key == setup.ListContainerKey); + var blockListVal = setup.JsonSerializer.Deserialize(listContainerData.Values.First(v => v.Alias == "blockList").Value!.ToString()!); + + // Verify 3 blocks in contentData + Assert.AreEqual(3, blockListVal!.ContentData.Count); + + // Verify new block was appended + var newBlock = blockListVal.ContentData.FirstOrDefault(b => b.Key == newBlockKey); + Assert.IsNotNull(newBlock); + Assert.AreEqual("new block en", newBlock!.Values.FirstOrDefault(v => v.Alias == "text" && v.Culture == "en-US")?.Value?.ToString()); + + // Verify originals unchanged + Assert.AreEqual("original en", blockListVal.ContentData.First(b => b.Key == setup.TextBlock1Key).Values.First(v => v.Alias == "text" && v.Culture == "en-US").Value?.ToString()); + Assert.AreEqual("second block en", blockListVal.ContentData.First(b => b.Key == setup.TextBlock2Key).Values.First(v => v.Alias == "text" && v.Culture == "en-US").Value?.ToString()); + + // Verify layout has 3 entries + var layoutItems = blockListVal.Layout[Constants.PropertyEditors.Aliases.BlockList].ToList(); + Assert.AreEqual(3, layoutItems.Count); + } + + [Test] + public async Task PatchDocument_DeeplyNestedBlocks_InsertsBlockAtSpecificPosition() + { + // Arrange + await AuthenticateClientAsync(Client, "test@umbraco.com", UserPassword, isAdmin: true); + var setup = await CreateDeeplyNestedBlockDocument(); + + var prefix = setup.BlockListPathPrefix("en-US"); + var newBlockKey = Guid.NewGuid(); + + // Insert at index 1 (between the two existing blocks) + var patchModel = new PatchDocumentRequestModel + { + Operations = new[] + { + new PatchOperationRequestModel + { + Op = "add", + Path = $"{prefix}/contentData/1", + Value = new + { + key = newBlockKey, + contentTypeKey = setup.TextBlockTypeKey, + values = new[] + { + new { alias = "text", culture = "en-US", value = "inserted block en" }, + new { alias = "text", culture = "da-DK", value = "inserted block da" } + } + } + }, + new PatchOperationRequestModel + { + Op = "add", + Path = $"{prefix}/layout/Umbraco.BlockList/1", + Value = new { contentKey = newBlockKey } + }, + new PatchOperationRequestModel + { + Op = "add", + Path = $"{prefix}/expose/1", + Value = new { contentKey = newBlockKey, culture = "en-US" } + } + } + }; + + // Act + var httpContent = JsonContent.Create(patchModel); + httpContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json-patch+json"); + var response = await Client.PatchAsync($"/umbraco/management/api/v1/document/{setup.DocumentKey}/patch", httpContent); + + // Assert + if (response.StatusCode != HttpStatusCode.OK) + { + var errorContent = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Error response: {response.StatusCode}"); + Console.WriteLine($"Error body: {errorContent}"); + } + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Parse through the nested structure to the block list + var updatedContent = await ContentEditingService.GetAsync(setup.DocumentKey); + var rteValue = updatedContent.GetValue("rte", "en-US"); + RichTextPropertyEditorHelper.TryParseRichTextEditorValue(rteValue, setup.JsonSerializer, Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance, out var parsedRte); + var gridContainerData = parsedRte!.Blocks!.ContentData.First(b => b.Key == setup.GridContainerKey); + var blockGridVal = setup.JsonSerializer.Deserialize(gridContainerData.Values.First(v => v.Alias == "blockGrid").Value!.ToString()!); + var listContainerData = blockGridVal!.ContentData.First(b => b.Key == setup.ListContainerKey); + var blockListVal = setup.JsonSerializer.Deserialize(listContainerData.Values.First(v => v.Alias == "blockList").Value!.ToString()!); + + // Verify 3 blocks in contentData + Assert.AreEqual(3, blockListVal!.ContentData.Count); + + // Verify insertion order: original1, inserted, original2 + Assert.AreEqual(setup.TextBlock1Key, blockListVal.ContentData[0].Key); + Assert.AreEqual(newBlockKey, blockListVal.ContentData[1].Key); + Assert.AreEqual(setup.TextBlock2Key, blockListVal.ContentData[2].Key); + + // Verify inserted block values + Assert.AreEqual("inserted block en", blockListVal.ContentData[1].Values.First(v => v.Alias == "text" && v.Culture == "en-US").Value?.ToString()); + + // Verify layout order matches + var layoutItems = blockListVal.Layout[Constants.PropertyEditors.Aliases.BlockList].ToList(); + Assert.AreEqual(3, layoutItems.Count); + } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathCultureExtractorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathCultureExtractorTests.cs deleted file mode 100644 index de196c1e1cbc..000000000000 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/PropertyEditors/JsonPath/JsonPathCultureExtractorTests.cs +++ /dev/null @@ -1,193 +0,0 @@ -using NUnit.Framework; -using Umbraco.Cms.Core.PropertyEditors.JsonPath; - -namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Infrastructure.PropertyEditors.JsonPath; - -[TestFixture] -public class JsonPathCultureExtractorTests -{ - private JsonPathCultureExtractor _extractor = null!; - - [SetUp] - public void SetUp() - { - _extractor = new JsonPathCultureExtractor(); - } - - [Test] - public void ExtractCultures_SingleCulture_ReturnsCulture() - { - var path = "/values[alias=title,culture=en-US]/value"; - - var cultures = _extractor.ExtractCultures(path); - - Assert.That(cultures, Has.Count.EqualTo(1)); - Assert.That(cultures, Does.Contain("en-US")); - } - - [Test] - public void ExtractCultures_MultiplePaths_ReturnAllCultures() - { - var path1 = "/values[culture=en-US]/value"; - var path2 = "/values[culture=da-DK]/value"; - - var cultures1 = _extractor.ExtractCultures(path1); - var cultures2 = _extractor.ExtractCultures(path2); - - Assert.That(cultures1, Does.Contain("en-US")); - Assert.That(cultures2, Does.Contain("da-DK")); - } - - [Test] - public void ExtractCultures_NoCulture_ReturnsEmpty() - { - var path = "/values[alias=title]/value"; - - var cultures = _extractor.ExtractCultures(path); - - Assert.That(cultures, Is.Empty); - } - - [Test] - public void ExtractCultures_EmptyString_ReturnsEmpty() - { - var cultures = _extractor.ExtractCultures(string.Empty); - - Assert.That(cultures, Is.Empty); - } - - [Test] - public void ExtractSegments_SingleSegment_ReturnsSegment() - { - var path = "/values[alias=price,segment=premium]/value"; - - var segments = _extractor.ExtractSegments(path); - - Assert.That(segments, Has.Count.EqualTo(1)); - Assert.That(segments, Does.Contain("premium")); - } - - [Test] - public void ExtractSegments_NoSegment_ReturnsEmpty() - { - var path = "/values[alias=title]/value"; - - var segments = _extractor.ExtractSegments(path); - - Assert.That(segments, Is.Empty); - } - - [Test] - public void ContainsInvariantCultureFilter_NullCulture_ReturnsTrue() - { - var path = "/values[alias=title,culture=null]/value"; - - var containsInvariant = _extractor.ContainsInvariantCultureFilter(path); - - Assert.That(containsInvariant, Is.True); - } - - [Test] - public void ContainsInvariantCultureFilter_CultureValue_ReturnsFalse() - { - var path = "/values[alias=title,culture=en-US]/value"; - - var containsInvariant = _extractor.ContainsInvariantCultureFilter(path); - - Assert.That(containsInvariant, Is.False); - } - - [Test] - public void ContainsInvariantCultureFilter_NoCultureFilter_ReturnsFalse() - { - var path = "/values[alias=title]/value"; - - var containsInvariant = _extractor.ContainsInvariantCultureFilter(path); - - Assert.That(containsInvariant, Is.False); - } - - [Test] - public void ContainsNullSegmentFilter_NullSegment_ReturnsTrue() - { - var path = "/values[alias=price,segment=null]/value"; - - var containsNullSegment = _extractor.ContainsNullSegmentFilter(path); - - Assert.That(containsNullSegment, Is.True); - } - - [Test] - public void ContainsNullSegmentFilter_SegmentValue_ReturnsFalse() - { - var path = "/values[alias=price,segment=premium]/value"; - - var containsNullSegment = _extractor.ContainsNullSegmentFilter(path); - - Assert.That(containsNullSegment, Is.False); - } - - [Test] - public void ExtractCulturesFromOperations_MultipleOperations_ReturnsAllUniqueCultures() - { - var operations = new[] - { - "/values[culture=en-US]/value", - "/values[culture=da-DK]/value", - "/values[culture=en-US]/value" // Duplicate - }; - - var cultures = _extractor.ExtractCulturesFromOperations(operations); - - Assert.That(cultures, Has.Count.EqualTo(2)); - Assert.That(cultures, Does.Contain("en-US")); - Assert.That(cultures, Does.Contain("da-DK")); - } - - [Test] - public void ExtractCulturesFromOperations_EmptyCollection_ReturnsEmpty() - { - var cultures = _extractor.ExtractCulturesFromOperations(Array.Empty()); - - Assert.That(cultures, Is.Empty); - } - - [Test] - public void AnyOperationTargetsInvariantCulture_ContainsInvariant_ReturnsTrue() - { - var operations = new[] - { - "/values[culture=en-US]/value", - "/values[culture=null]/value" - }; - - var targetsInvariant = _extractor.AnyOperationTargetsInvariantCulture(operations); - - Assert.That(targetsInvariant, Is.True); - } - - [Test] - public void AnyOperationTargetsInvariantCulture_NoInvariant_ReturnsFalse() - { - var operations = new[] - { - "/values[culture=en-US]/value", - "/values[culture=da-DK]/value" - }; - - var targetsInvariant = _extractor.AnyOperationTargetsInvariantCulture(operations); - - Assert.That(targetsInvariant, Is.False); - } - - [Test] - public void ExtractCultures_ComplexNestedPath_ExtractsCulture() - { - var path = "/values[alias=contentBlocks]/value/contentData[key=some-guid]/values[alias=headline,culture=en-US]/value"; - - var cultures = _extractor.ExtractCultures(path); - - Assert.That(cultures, Has.Count.EqualTo(1)); - Assert.That(cultures, Does.Contain("en-US")); - } -} From 83a60bd09c832dd7737650548755821bf6f7e2c9 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 11 Mar 2026 16:18:01 +0100 Subject: [PATCH 13/39] Restore things that are breaking --- Directory.Packages.props | 1 + src/Umbraco.Cms.Api.Management/CLAUDE.md | 1 + .../Document/PatchDocumentController.cs | 1 - .../JsonBuilderExtensions.cs | 27 ++++++++++++++++ .../UmbracoBuilderExtensions.cs | 6 +++- .../Services/IJsonPatchService.cs | 19 +++++++++++ .../Services/JsonPatchService.cs | 32 +++++++++++++++++++ .../Umbraco.Cms.Api.Management.csproj | 1 + .../JsonPatch/JsonPatchViewModel.cs | 28 ++++++++++++++++ 9 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/IJsonPatchService.cs create mode 100644 src/Umbraco.Cms.Api.Management/Services/JsonPatchService.cs create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/JsonPatch/JsonPatchViewModel.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index ad81a639d146..8a32d0745480 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -48,6 +48,7 @@ + diff --git a/src/Umbraco.Cms.Api.Management/CLAUDE.md b/src/Umbraco.Cms.Api.Management/CLAUDE.md index c84fc409df55..2151bb1c9345 100644 --- a/src/Umbraco.Cms.Api.Management/CLAUDE.md +++ b/src/Umbraco.Cms.Api.Management/CLAUDE.md @@ -27,6 +27,7 @@ RESTful API for Umbraco backoffice operations. Manages content, media, users, an - **Serialization**: System.Text.Json with custom converters - **Mapping**: Manual presentation factories (no AutoMapper) - **Patching**: Custom patch engine for PATCH operations (Umbraco.Core.PropertyEditors.Patching) + - ⚠️ Legacy JsonPatch.Net support (IJsonPatchService) still available but **obsolete** - scheduled for removal in v19 - **Real-time**: SignalR hubs (`BackofficeHub`, `ServerEventHub`) - **DI**: Microsoft.Extensions.DependencyInjection via `ManagementApiComposer` diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs index 4bf78341a590..1b7ad2919c42 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs @@ -49,7 +49,6 @@ public async Task Patch( PatchDocumentRequestModel requestModel) => await HandleRequest(id, requestModel, async () => { - // Map request model to domain model ContentPatchModel patchModel = _presentationFactory.MapPatchModel(requestModel); // Apply PATCH operations to create an update request model diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs new file mode 100644 index 000000000000..54710d139695 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs @@ -0,0 +1,27 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.DependencyInjection; +using Umbraco.Cms.Api.Management.Services; + +namespace Umbraco.Cms.Api.Management.DependencyInjection; + +/// +/// Extension methods for registering JSON-related services. +/// +[Obsolete("JsonPatch.Net dependency and IJsonPatchService are being removed. Use the custom patch engine (DocumentPatcher) instead. Scheduled for removal in Umbraco 19.")] +public static class JsonBuilderExtensions +{ + /// + /// Adds JSON-related services to the Umbraco builder. + /// + /// The Umbraco builder. + /// The Umbraco builder. + internal static IUmbracoBuilder AddJson(this IUmbracoBuilder builder) + { +#pragma warning disable CS0618 // Type or member is obsolete + builder.Services + .AddTransient(); +#pragma warning restore CS0618 // Type or member is obsolete + + return builder; + } +} diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs index 05409fdff043..6ffa8f7339c4 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs @@ -24,10 +24,14 @@ public static IUmbracoBuilder AddUmbracoManagementApi(this IUmbracoBuilder build builder.Services.AddUnique(); builder.AddUmbracoApiOpenApiUI(); - if (!services.Any(x => !x.IsKeyedService && x.ImplementationType == typeof(ConfigureUmbracoBackofficeJsonOptions))) +#pragma warning disable CS0618 // Type or member is obsolete + if (!services.Any(x => !x.IsKeyedService && x.ImplementationType == typeof(JsonPatchService))) +#pragma warning restore CS0618 // Type or member is obsolete { +#pragma warning disable CS0618 // Type or member is obsolete ModelsBuilderBuilderExtensions.AddModelsBuilder(builder) .AddJson() +#pragma warning restore CS0618 // Type or member is obsolete .AddInstaller() .AddUpgrader() .AddSearchManagement() diff --git a/src/Umbraco.Cms.Api.Management/Services/IJsonPatchService.cs b/src/Umbraco.Cms.Api.Management/Services/IJsonPatchService.cs new file mode 100644 index 000000000000..d3faf9bce98b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/IJsonPatchService.cs @@ -0,0 +1,19 @@ +using Json.Patch; +using Umbraco.Cms.Api.Management.ViewModels.JsonPatch; + +namespace Umbraco.Cms.Api.Management.Services; + +/// +/// Service for applying JSON Patch operations using JsonPatch.Net. +/// +[Obsolete("Use the custom patch engine (DocumentPatcher) instead. JsonPatch.Net dependency is being removed. Scheduled for removal in Umbraco 19.")] +public interface IJsonPatchService +{ + /// + /// Applies JSON Patch operations to an object. + /// + /// The patch operations to apply. + /// The object to patch. + /// The result of the patch operation. + PatchResult? Patch(JsonPatchViewModel[] patchViewModel, object objectToPatch); +} diff --git a/src/Umbraco.Cms.Api.Management/Services/JsonPatchService.cs b/src/Umbraco.Cms.Api.Management/Services/JsonPatchService.cs new file mode 100644 index 000000000000..27bdce434c92 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Services/JsonPatchService.cs @@ -0,0 +1,32 @@ +using System.Text.Json.Nodes; +using Json.Patch; +using Umbraco.Cms.Api.Management.ViewModels.JsonPatch; +using Umbraco.Cms.Core.Serialization; + +namespace Umbraco.Cms.Api.Management.Services; + +/// +/// Service for applying JSON Patch operations using JsonPatch.Net. +/// +[Obsolete("Use the custom patch engine (DocumentPatcher) instead. JsonPatch.Net dependency is being removed. Scheduled for removal in Umbraco 19.")] +public class JsonPatchService : IJsonPatchService +{ + private readonly IJsonSerializer _jsonSerializer; + + /// + /// Initializes a new instance of the class. + /// + /// The JSON serializer. + public JsonPatchService(IJsonSerializer jsonSerializer) => _jsonSerializer = jsonSerializer; + + /// + public PatchResult? Patch(JsonPatchViewModel[] patchViewModel, object objectToPatch) + { + var patchString = _jsonSerializer.Serialize(patchViewModel); + + var docString = _jsonSerializer.Serialize(objectToPatch); + JsonPatch? patch = _jsonSerializer.Deserialize(patchString); + var doc = JsonNode.Parse(docString); + return patch?.Apply(doc); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj b/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj index fa39a7881c4a..14676c2e49a5 100644 --- a/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj +++ b/src/Umbraco.Cms.Api.Management/Umbraco.Cms.Api.Management.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/JsonPatch/JsonPatchViewModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/JsonPatch/JsonPatchViewModel.cs new file mode 100644 index 000000000000..c8e9fb1a086d --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/JsonPatch/JsonPatchViewModel.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace Umbraco.Cms.Api.Management.ViewModels.JsonPatch; + +/// +/// View model for JSON Patch operations using JsonPatch.Net. +/// +[Obsolete("Use PatchDocumentRequestModel and the custom patch engine instead. JsonPatch.Net dependency is being removed. Scheduled for removal in Umbraco 19.")] +public class JsonPatchViewModel +{ + /// + /// Gets or sets the operation type (e.g., "replace", "add", "remove"). + /// + [Required] + public string Op { get; set; } = string.Empty; + + /// + /// Gets or sets the JSON Pointer path to the target location. + /// + [Required] + public string Path { get; set; } = string.Empty; + + /// + /// Gets or sets the value for the operation. + /// + [Required] + public object Value { get; set; } = null!; +} From 746cecedc1d5dacdb5768ceb56c949339c372305 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 11 Mar 2026 16:45:47 +0100 Subject: [PATCH 14/39] cleanup --- .../Factories/DocumentEditingPresentationFactory.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs index 5c2172a7829b..9563c7b88ffc 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs @@ -41,13 +41,10 @@ public ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel public async Task CreateUpdateRequestModelAsync(IContent content) { - // Map values (similar to ContentMapDefinition.MapValueViewModels) - var values = MapValuesToRequestModel(content.Properties); + DocumentValueModel[] values = MapValuesToRequestModel(content.Properties); - // Map variants (culture/segment with name) - var variants = MapVariantsToRequestModel(content); + DocumentVariantRequestModel[] variants = MapVariantsToRequestModel(content); - // Map template Guid? templateKey = content.TemplateId.HasValue ? (await _templateService.GetAsync(content.TemplateId.Value))?.Key : null; @@ -56,7 +53,7 @@ public async Task CreateUpdateRequestModelAsync(ICon { Values = values, Variants = variants, - Template = templateKey.HasValue ? new ReferenceByIdModel { Id = templateKey.Value } : null + Template = templateKey.HasValue ? new ReferenceByIdModel { Id = templateKey.Value } : null, }; } From 4501d21429d9468b5f9ec31ace59f9b8186562c0 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 11 Mar 2026 19:19:53 +0100 Subject: [PATCH 15/39] Namespace cleanup --- .../Document/PatchDocumentController.cs | 1 + .../DocumentEditingPresentationFactory.cs | 5 ++-- .../IDocumentEditingPresentationFactory.cs | 1 + .../Patchers/DocumentPatcher.cs | 4 ++-- .../Patching/PatchEngine.cs | 4 ++-- .../Patching/PatchPathParser.cs | 2 +- .../Patching/PatchPathResolver.cs | 2 +- .../Patching/PatchPathSegment.cs | 2 +- .../ViewModels/Patching}/ContentPatchModel.cs | 8 +++---- .../Patching}/PatchOperationModel.cs | 23 +------------------ .../ViewModels/Patching/PatchOperationType.cs | 22 ++++++++++++++++++ .../Patching/PatchEngineTests.cs | 6 ++--- .../Patching/PatchPathParserTests.cs | 4 ++-- 13 files changed, 44 insertions(+), 40 deletions(-) rename src/{Umbraco.Core/PropertyEditors => Umbraco.Cms.Api.Management}/Patching/PatchEngine.cs (97%) rename src/{Umbraco.Core/PropertyEditors => Umbraco.Cms.Api.Management}/Patching/PatchPathParser.cs (99%) rename src/{Umbraco.Core/PropertyEditors => Umbraco.Cms.Api.Management}/Patching/PatchPathResolver.cs (99%) rename src/{Umbraco.Core/PropertyEditors => Umbraco.Cms.Api.Management}/Patching/PatchPathSegment.cs (95%) rename src/{Umbraco.Core/Models/ContentEditing => Umbraco.Cms.Api.Management/ViewModels/Patching}/ContentPatchModel.cs (65%) rename src/{Umbraco.Core/Models/ContentEditing => Umbraco.Cms.Api.Management/ViewModels/Patching}/PatchOperationModel.cs (56%) create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationType.cs rename tests/Umbraco.Tests.UnitTests/{Umbraco.Core/PropertyEditors => Umbraco.Cms.Api.Management}/Patching/PatchEngineTests.cs (99%) rename tests/Umbraco.Tests.UnitTests/{Umbraco.Core/PropertyEditors => Umbraco.Cms.Api.Management}/Patching/PatchPathParserTests.cs (98%) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs index 1b7ad2919c42..129d812f3394 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs @@ -6,6 +6,7 @@ using Umbraco.Cms.Api.Management.OperationStatus; using Umbraco.Cms.Api.Management.Patchers; using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Patching; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs index 9563c7b88ffc..13b4cdae8818 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs @@ -1,9 +1,10 @@ -using Umbraco.Cms.Api.Management.ViewModels; +using Umbraco.Cms.Api.Management.Patching; +using Umbraco.Cms.Api.Management.ViewModels; using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Patching; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.PropertyEditors; -using Umbraco.Cms.Core.PropertyEditors.Patching; using Umbraco.Cms.Core.Services; using Umbraco.Extensions; diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs index 33c6de8e7ad4..0282f1ac42eb 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs @@ -1,4 +1,5 @@ using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Patching; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; diff --git a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs index b5451a24a042..a9f43ea72425 100644 --- a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs +++ b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs @@ -1,10 +1,10 @@ using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.OperationStatus; +using Umbraco.Cms.Api.Management.Patching; using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Patching; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Models.ContentEditing; -using Umbraco.Cms.Core.PropertyEditors.Patching; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; diff --git a/src/Umbraco.Core/PropertyEditors/Patching/PatchEngine.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs similarity index 97% rename from src/Umbraco.Core/PropertyEditors/Patching/PatchEngine.cs rename to src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs index 282c94f71ab6..5ddbc4479999 100644 --- a/src/Umbraco.Core/PropertyEditors/Patching/PatchEngine.cs +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs @@ -1,8 +1,8 @@ using System.Text.Json; using System.Text.Json.Nodes; -using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Api.Management.ViewModels.Patching; -namespace Umbraco.Cms.Core.PropertyEditors.Patching; +namespace Umbraco.Cms.Api.Management.Patching; /// /// Applies patch operations to JSON documents using Umbraco's custom path syntax. diff --git a/src/Umbraco.Core/PropertyEditors/Patching/PatchPathParser.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs similarity index 99% rename from src/Umbraco.Core/PropertyEditors/Patching/PatchPathParser.cs rename to src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs index 2a96bfef0769..80524ffe86cf 100644 --- a/src/Umbraco.Core/PropertyEditors/Patching/PatchPathParser.cs +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs @@ -1,4 +1,4 @@ -namespace Umbraco.Cms.Core.PropertyEditors.Patching; +namespace Umbraco.Cms.Api.Management.Patching; /// /// Parses Umbraco's patch path syntax into typed segments. diff --git a/src/Umbraco.Core/PropertyEditors/Patching/PatchPathResolver.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs similarity index 99% rename from src/Umbraco.Core/PropertyEditors/Patching/PatchPathResolver.cs rename to src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs index 75da61574fc3..46348a60da12 100644 --- a/src/Umbraco.Core/PropertyEditors/Patching/PatchPathResolver.cs +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs @@ -1,6 +1,6 @@ using System.Text.Json.Nodes; -namespace Umbraco.Cms.Core.PropertyEditors.Patching; +namespace Umbraco.Cms.Api.Management.Patching; /// /// The result of resolving a patch path against a JSON document. diff --git a/src/Umbraco.Core/PropertyEditors/Patching/PatchPathSegment.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchPathSegment.cs similarity index 95% rename from src/Umbraco.Core/PropertyEditors/Patching/PatchPathSegment.cs rename to src/Umbraco.Cms.Api.Management/Patching/PatchPathSegment.cs index c2e73584af5b..1a41a27b2293 100644 --- a/src/Umbraco.Core/PropertyEditors/Patching/PatchPathSegment.cs +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchPathSegment.cs @@ -1,4 +1,4 @@ -namespace Umbraco.Cms.Core.PropertyEditors.Patching; +namespace Umbraco.Cms.Api.Management.Patching; /// /// Represents a single segment in a patch path expression. diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Patching/ContentPatchModel.cs similarity index 65% rename from src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs rename to src/Umbraco.Cms.Api.Management/ViewModels/Patching/ContentPatchModel.cs index 3ffd5124bb64..917d0ca5fb77 100644 --- a/src/Umbraco.Core/Models/ContentEditing/ContentPatchModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Patching/ContentPatchModel.cs @@ -1,4 +1,4 @@ -namespace Umbraco.Cms.Core.Models.ContentEditing; +namespace Umbraco.Cms.Api.Management.ViewModels.Patching; /// /// Model for operation-based partial content updates using Umbraco's extended JSON Pointer path syntax. @@ -6,18 +6,18 @@ namespace Umbraco.Cms.Core.Models.ContentEditing; public class ContentPatchModel { /// - /// Collection of PATCH operations to apply. + /// Gets or sets collection of PATCH operations to apply. /// public PatchOperationModel[] Operations { get; set; } = Array.Empty(); /// - /// Cultures explicitly affected by this patch (extracted from operation paths). + /// Gets or sets cultures explicitly affected by this patch (extracted from operation paths). /// Used for authorization checks. /// public IEnumerable AffectedCultures { get; set; } = Array.Empty(); /// - /// Segments explicitly affected by this patch (extracted from operation paths). + /// Gets or sets segments explicitly affected by this patch (extracted from operation paths). /// public IEnumerable AffectedSegments { get; set; } = Array.Empty(); diff --git a/src/Umbraco.Core/Models/ContentEditing/PatchOperationModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationModel.cs similarity index 56% rename from src/Umbraco.Core/Models/ContentEditing/PatchOperationModel.cs rename to src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationModel.cs index f3247ad887ae..c10377c85aef 100644 --- a/src/Umbraco.Core/Models/ContentEditing/PatchOperationModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationModel.cs @@ -1,4 +1,4 @@ -namespace Umbraco.Cms.Core.Models.ContentEditing; +namespace Umbraco.Cms.Api.Management.ViewModels.Patching; /// /// Represents a single PATCH operation in the domain layer. @@ -20,24 +20,3 @@ public class PatchOperationModel /// public object? Value { get; set; } } - -/// -/// Defines the supported PATCH operation types. -/// -public enum PatchOperationType -{ - /// - /// Replace an existing value at the target location. - /// - Replace, - - /// - /// Add a new value at the target location. - /// - Add, - - /// - /// Remove the value at the target location. - /// - Remove -} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationType.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationType.cs new file mode 100644 index 000000000000..e6632d91d441 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationType.cs @@ -0,0 +1,22 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Patching; + +/// +/// Defines the supported PATCH operation types. +/// +public enum PatchOperationType +{ + /// + /// Replace an existing value at the target location. + /// + Replace, + + /// + /// Add a new value at the target location. + /// + Add, + + /// + /// Remove the value at the target location. + /// + Remove, +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchEngineTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchEngineTests.cs similarity index 99% rename from tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchEngineTests.cs rename to tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchEngineTests.cs index a1a7e57ed434..e32ced3838cc 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchEngineTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchEngineTests.cs @@ -1,9 +1,9 @@ using System.Text.Json; using NUnit.Framework; -using Umbraco.Cms.Core.Models.ContentEditing; -using Umbraco.Cms.Core.PropertyEditors.Patching; +using Umbraco.Cms.Api.Management.Patching; +using Umbraco.Cms.Api.Management.ViewModels.Patching; -namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors.Patching; +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Patching; [TestFixture] public class PatchEngineTests diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchPathParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathParserTests.cs similarity index 98% rename from tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchPathParserTests.cs rename to tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathParserTests.cs index 6d5e6a29fea6..0a20157e8eae 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/Patching/PatchPathParserTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathParserTests.cs @@ -1,7 +1,7 @@ using NUnit.Framework; -using Umbraco.Cms.Core.PropertyEditors.Patching; +using Umbraco.Cms.Api.Management.Patching; -namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.PropertyEditors.Patching; +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Patching; [TestFixture] public class PatchPathParserTests From 9e9fde6ac6ca2460181cca4d2004224805bb224a Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 11 Mar 2026 19:23:36 +0100 Subject: [PATCH 16/39] Order cleanup --- .../DocumentEditingPresentationFactory.cs | 83 +++++++++---------- 1 file changed, 41 insertions(+), 42 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs index 13b4cdae8818..0c2450a3ccd9 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs @@ -58,6 +58,43 @@ public async Task CreateUpdateRequestModelAsync(ICon }; } + public ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel) + { + PatchOperationModel[] operations = requestModel.Operations.Select(op => new PatchOperationModel + { + Op = MapOperationType(op.Op), + Path = op.Path, + Value = op.Value, + }).ToArray(); + + var paths = operations.Select(o => o.Path).ToArray(); + + var affectedCultures = paths + .SelectMany(PatchPathParser.ExtractCultures) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var affectedSegments = paths + .SelectMany(PatchPathParser.ExtractSegments) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new ContentPatchModel + { + Operations = operations, + AffectedCultures = affectedCultures, + AffectedSegments = affectedSegments, + }; + } + + public ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel) + { + ValidateContentUpdateModel model = MapUpdateContentModel(requestModel); + model.Cultures = requestModel.Cultures; + + return model; + } + private DocumentValueModel[] MapValuesToRequestModel(IPropertyCollection properties) { Dictionary missingPropertyEditors = []; @@ -90,6 +127,7 @@ private DocumentVariantRequestModel[] MapVariantsToRequestModel(IContent content { IPropertyValue[] propertyValues = content.Properties.SelectMany(propertyCollection => propertyCollection.Values).ToArray(); var cultures = content.AvailableCultures.DefaultIfEmpty(null).ToArray(); + // The default segment (null) must always be included var segments = propertyValues.Select(property => property.Segment).Union([null]).Distinct().ToArray(); @@ -103,53 +141,14 @@ private DocumentVariantRequestModel[] MapVariantsToRequestModel(IContent content .ToArray(); } - public ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel) - { - var operations = requestModel.Operations.Select(op => new PatchOperationModel - { - Op = MapOperationType(op.Op), - Path = op.Path, - Value = op.Value - }).ToArray(); - - var paths = operations.Select(o => o.Path).ToArray(); - - var affectedCultures = paths - .SelectMany(PatchPathParser.ExtractCultures) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - var affectedSegments = paths - .SelectMany(PatchPathParser.ExtractSegments) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - return new ContentPatchModel - { - Operations = operations, - AffectedCultures = affectedCultures, - AffectedSegments = affectedSegments - }; - } - - private static PatchOperationType MapOperationType(string op) - { - return op.ToLowerInvariant() switch + private static PatchOperationType MapOperationType(string op) => + op.ToLowerInvariant() switch { "replace" => PatchOperationType.Replace, "add" => PatchOperationType.Add, "remove" => PatchOperationType.Remove, - _ => throw new ArgumentException($"Unsupported operation type: {op}", nameof(op)) + _ => throw new ArgumentException($"Unsupported operation type: {op}", nameof(op)), }; - } - - public ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel) - { - ValidateContentUpdateModel model = MapUpdateContentModel(requestModel); - model.Cultures = requestModel.Cultures; - - return model; - } private TUpdateModel MapUpdateContentModel(UpdateDocumentRequestModel requestModel) where TUpdateModel : ContentUpdateModel, new() From cbec75925d42f3343d839de5c4a1704e3e6178ce Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 11 Mar 2026 21:13:14 +0100 Subject: [PATCH 17/39] More comment updates --- .../IDocumentEditingPresentationFactory.cs | 21 +++++++++++++++++++ .../Patchers/DocumentPatcher.cs | 2 +- .../Patching/PatchEngine.cs | 4 ++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs index 0282f1ac42eb..651e335b5e45 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs @@ -7,8 +7,18 @@ namespace Umbraco.Cms.Api.Management.Factories; public interface IDocumentEditingPresentationFactory { + /// + /// Maps a to a for content creation. + /// + /// The create document request model. + /// A ready for content creation. ContentCreateModel MapCreateModel(CreateDocumentRequestModel requestModel); + /// + /// Maps an to a for content updating. + /// + /// The update document request model. + /// A ready for content updating. ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel); /// @@ -19,7 +29,18 @@ public interface IDocumentEditingPresentationFactory /// An UpdateDocumentRequestModel representing the content. Task CreateUpdateRequestModelAsync(IContent content); + /// + /// Maps a to a , + /// extracting the affected cultures and segments from the patch operation paths. + /// + /// The patch document request model. + /// A containing the mapped operations and affected cultures/segments. ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel); + /// + /// Maps a to a for update validation. + /// + /// The validate update document request model. + /// A ready for validation. ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel); } diff --git a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs index a9f43ea72425..fe143e58da12 100644 --- a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs +++ b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs @@ -11,7 +11,7 @@ namespace Umbraco.Cms.Api.Management.Patchers; /// -/// Applies patch operations with Umbraco's custom path syntax to documents, converting them to update models. +/// Applies patch operations with Umbraco's custom path syntax to document update models. /// public class DocumentPatcher { diff --git a/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs index 5ddbc4479999..4f28883e7a4e 100644 --- a/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs @@ -5,7 +5,8 @@ namespace Umbraco.Cms.Api.Management.Patching; /// -/// Applies patch operations to JSON documents using Umbraco's custom path syntax. +/// Applies patch operations to JSON documents using Umbraco's custom path syntax based on Json pointer +/// augmented with array filtering to support culture/segment and keyed items. /// public static class PatchEngine { @@ -92,7 +93,6 @@ private static void ApplyAdd(ResolvedTarget target, JsonNode? valueNode) { if (target.IsAppend) { - // Append to end of array if (target.Parent is JsonArray appendArray) { appendArray.Add(valueNode?.DeepClone()); From 6e00a16f16a664c6bc71bb9c67d8eea8d1b55d29 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 24 Mar 2026 11:56:38 +0100 Subject: [PATCH 18/39] Add default implementations --- .../Factories/IDocumentEditingPresentationFactory.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs index c81035a6192c..4beb829148b1 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs @@ -1,4 +1,4 @@ -using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Api.Management.ViewModels.Patching; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; @@ -32,18 +32,20 @@ public interface IDocumentEditingPresentationFactory /// An representing the content. Task CreateUpdateRequestModelAsync(IContent content); + // TODO (V19): Remove the default implementation. /// /// Maps a to a , /// extracting the affected cultures and segments from the patch operation paths. /// /// The patch document request model. /// A containing the mapped operations and affected cultures/segments. - ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel); + ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel) => throw new NotImplementedException(); + // TODO (V19): Remove the default implementation. /// /// Maps a to a for update validation. /// /// The validate update document request model. /// A ready for validation. - ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel); + ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel) => throw new NotImplementedException(); } From 7a9847b3e5ec9d49b27690d14a8e98bf78f811eb Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 24 Mar 2026 11:57:39 +0100 Subject: [PATCH 19/39] Improve modelbinding validation --- .../ViewModels/Document/PatchDocumentRequestModel.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs index 3900f8a7293e..fca5a3ac8197 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs @@ -13,5 +13,6 @@ public class PatchDocumentRequestModel /// [Required] [MinLength(1)] + [AllowedValues("add", "replace", "remove", ErrorMessage = "Supported operations are: add, replace, remove.")] public PatchOperationRequestModel[] Operations { get; set; } = Array.Empty(); } From 19ba6dee1ab78e09d9c7af8a8605f8329c7ec468 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 24 Mar 2026 11:58:38 +0100 Subject: [PATCH 20/39] all string comparison --- src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs index 46348a60da12..eaa43a2d756c 100644 --- a/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs @@ -180,7 +180,7 @@ private static bool MatchesAllConditions(JsonObject element, FilterCondition[] c } var nodeValue = propertyNode.GetValue(); - if (!string.Equals(nodeValue, condition.Value, StringComparison.Ordinal)) + if (!string.Equals(nodeValue, condition.Value, StringComparison.OrdinalIgnoreCase)) { return false; } From 632954a30b8a2d550123fe4ad3673c766514fa92 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 24 Mar 2026 12:00:12 +0100 Subject: [PATCH 21/39] Cleanup unused statuses --- .../Document/PatchDocumentControllerBase.cs | 11 ----------- .../ContentPatchingOperationStatus.cs | 15 --------------- 2 files changed, 26 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs index 9da9eacf6084..cea4b6d0bb1f 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs @@ -46,20 +46,9 @@ protected IActionResult ContentPatchingOperationStatusResult(ContentPatchingOper .WithTitle("Invalid operation") .WithDetail("One or more PATCH operations were invalid. Check operation structure, path syntax, and operation types.") .Build()), - ContentPatchingOperationStatus.InvalidCulture => BadRequest(problemDetailsBuilder - .WithTitle("Invalid culture") - .WithDetail("One or more cultures specified in operation paths are not valid or not configured.") - .Build()), ContentPatchingOperationStatus.NotFound => NotFound(problemDetailsBuilder .WithTitle("The document could not be found") .Build()), - ContentPatchingOperationStatus.ContentTypeNotFound => NotFound(problemDetailsBuilder - .WithTitle("The document's content type could not be found") - .Build()), - ContentPatchingOperationStatus.PropertyTypeNotFound => UnprocessableEntity(problemDetailsBuilder - .WithTitle("Property type not found") - .WithDetail("One or more specified properties do not exist on the content type.") - .Build()), _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder .WithTitle("Unknown error") .WithDetail("An unexpected error occurred during the PATCH operation.") diff --git a/src/Umbraco.Cms.Api.Management/OperationStatus/ContentPatchingOperationStatus.cs b/src/Umbraco.Cms.Api.Management/OperationStatus/ContentPatchingOperationStatus.cs index f6b26cdc50af..1979a7391472 100644 --- a/src/Umbraco.Cms.Api.Management/OperationStatus/ContentPatchingOperationStatus.cs +++ b/src/Umbraco.Cms.Api.Management/OperationStatus/ContentPatchingOperationStatus.cs @@ -16,23 +16,8 @@ public enum ContentPatchingOperationStatus /// InvalidOperation, - /// - /// One or more cultures specified in operation paths are not valid or not configured. - /// - InvalidCulture, - /// /// The target document could not be found. /// NotFound, - - /// - /// The document's content type could not be found. - /// - ContentTypeNotFound, - - /// - /// One or more property types specified in operations do not exist on the content type. - /// - PropertyTypeNotFound, } From 59175dda004099bf255312c454a30a958b697cb1 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 24 Mar 2026 12:14:40 +0100 Subject: [PATCH 22/39] Fix PatchPathResolver Filtering not accepting non string values --- .../Patching/PatchPathResolver.cs | 4 +- .../Patching/PatchPathResolverTests.cs | 153 ++++++++++++++++++ 2 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathResolverTests.cs diff --git a/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs index eaa43a2d756c..69b095bb7372 100644 --- a/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs @@ -179,8 +179,8 @@ private static bool MatchesAllConditions(JsonObject element, FilterCondition[] c return false; } - var nodeValue = propertyNode.GetValue(); - if (!string.Equals(nodeValue, condition.Value, StringComparison.OrdinalIgnoreCase)) + var nodeValue = propertyNode.ToString(); + if (!string.Equals(nodeValue, condition.Value, StringComparison.Ordinal)) { return false; } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathResolverTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathResolverTests.cs new file mode 100644 index 000000000000..c98939db58de --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathResolverTests.cs @@ -0,0 +1,153 @@ +using System.Text.Json.Nodes; +using NUnit.Framework; +using Umbraco.Cms.Api.Management.Patching; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Management.Patching; + +[TestFixture] +public class PatchPathResolverTests +{ + [Test] + public void Resolve_FilterWithNumericValue_MatchesElement() + { + var root = JsonNode.Parse(""" + { + "items": [ + { "id": 1, "name": "First" }, + { "id": 2, "name": "Second" }, + { "id": 3, "name": "Third" } + ] + } + """)!; + + var segments = new PatchPathSegment[] + { + new PropertySegment("items"), + new FilterSegment([new FilterCondition("id", "2")]), + new PropertySegment("name"), + }; + + var result = PatchPathResolver.Resolve(root, segments); + + Assert.That(result.Current?.GetValue(), Is.EqualTo("Second")); + } + + [Test] + public void Resolve_FilterWithBooleanValue_MatchesElement() + { + var root = JsonNode.Parse(""" + { + "items": [ + { "active": false, "name": "Inactive" }, + { "active": true, "name": "Active" } + ] + } + """)!; + + var segments = new PatchPathSegment[] + { + new PropertySegment("items"), + new FilterSegment([new FilterCondition("active", "true")]), + new PropertySegment("name"), + }; + + var result = PatchPathResolver.Resolve(root, segments); + + Assert.That(result.Current?.GetValue(), Is.EqualTo("Active")); + } + + [Test] + public void Resolve_FilterWithBooleanFalseValue_MatchesElement() + { + var root = JsonNode.Parse(""" + { + "items": [ + { "active": true, "name": "Active" }, + { "active": false, "name": "Inactive" } + ] + } + """)!; + + var segments = new PatchPathSegment[] + { + new PropertySegment("items"), + new FilterSegment([new FilterCondition("active", "false")]), + new PropertySegment("name"), + }; + + var result = PatchPathResolver.Resolve(root, segments); + + Assert.That(result.Current?.GetValue(), Is.EqualTo("Inactive")); + } + + [Test] + public void Resolve_FilterWithDecimalValue_MatchesElement() + { + var root = JsonNode.Parse(""" + { + "items": [ + { "score": 1.5, "name": "Low" }, + { "score": 9.9, "name": "High" } + ] + } + """)!; + + var segments = new PatchPathSegment[] + { + new PropertySegment("items"), + new FilterSegment([new FilterCondition("score", "9.9")]), + new PropertySegment("name"), + }; + + var result = PatchPathResolver.Resolve(root, segments); + + Assert.That(result.Current?.GetValue(), Is.EqualTo("High")); + } + + [Test] + public void Resolve_FilterWithNumericValueNoMatch_Throws() + { + var root = JsonNode.Parse(""" + { + "items": [ + { "id": 1, "name": "First" }, + { "id": 2, "name": "Second" } + ] + } + """)!; + + var segments = new PatchPathSegment[] + { + new PropertySegment("items"), + new FilterSegment([new FilterCondition("id", "99")]), + new PropertySegment("name"), + }; + + Assert.Throws(() => PatchPathResolver.Resolve(root, segments)); + } + + [Test] + public void Resolve_FilterWithMixedStringAndNumericConditions_MatchesElement() + { + var root = JsonNode.Parse(""" + { + "items": [ + { "type": "widget", "priority": 1, "name": "Low Widget" }, + { "type": "widget", "priority": 5, "name": "High Widget" }, + { "type": "gadget", "priority": 5, "name": "High Gadget" } + ] + } + """)!; + + var segments = new PatchPathSegment[] + { + new PropertySegment("items"), + new FilterSegment([new FilterCondition("type", "widget"), new FilterCondition("priority", "5")]), + new PropertySegment("name"), + }; + + var result = PatchPathResolver.Resolve(root, segments); + + Assert.That(result.Current?.GetValue(), Is.EqualTo("High Widget")); + } +} From 54a2edb25751a424206e932fab1a07d6fcbc1138 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 24 Mar 2026 12:19:23 +0100 Subject: [PATCH 23/39] Optimize path parsing --- .../Patching/PatchPathParser.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs index 80524ffe86cf..5aa262c6b3a0 100644 --- a/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace Umbraco.Cms.Api.Management.Patching; /// @@ -111,8 +113,9 @@ public static PatchPathSegment[] Parse(string path) /// /// The path expression to validate. /// True if the path is valid, false otherwise. - public static bool IsValid(string path) + public static bool IsValid(string path, [NotNullWhen(true)]out PatchPathSegment[]? parsedPath) { + parsedPath = null; if (string.IsNullOrWhiteSpace(path)) { return false; @@ -120,7 +123,7 @@ public static bool IsValid(string path) try { - Parse(path); + parsedPath = Parse(path); return true; } catch (FormatException) @@ -136,12 +139,11 @@ public static ISet ExtractCultures(string path) { var cultures = new HashSet(StringComparer.OrdinalIgnoreCase); - if (!IsValid(path)) + if (!IsValid(path, out PatchPathSegment[]? segments)) { return cultures; } - PatchPathSegment[] segments = Parse(path); foreach (PatchPathSegment segment in segments) { if (segment is FilterSegment filter) @@ -167,12 +169,11 @@ public static ISet ExtractSegments(string path) { var result = new HashSet(StringComparer.OrdinalIgnoreCase); - if (!IsValid(path)) + if (!IsValid(path, out PatchPathSegment[]? segments)) { return result; } - PatchPathSegment[] segments = Parse(path); foreach (PatchPathSegment segment in segments) { if (segment is FilterSegment filter) @@ -196,12 +197,11 @@ public static ISet ExtractSegments(string path) /// public static bool TargetsInvariantCulture(string path) { - if (!IsValid(path)) + if (!IsValid(path, out PatchPathSegment[]? segments)) { return false; } - PatchPathSegment[] segments = Parse(path); foreach (PatchPathSegment segment in segments) { if (segment is FilterSegment filter) From 2127de8ae6c9d1c3ac64226731e3037dbc6245cf Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 24 Mar 2026 12:24:39 +0100 Subject: [PATCH 24/39] Improve cookie token rework --- .../ManagementApi/ManagementApiTest.cs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/Umbraco.Tests.Integration/ManagementApi/ManagementApiTest.cs b/tests/Umbraco.Tests.Integration/ManagementApi/ManagementApiTest.cs index 4065f201f67c..3756ddfaeb75 100644 --- a/tests/Umbraco.Tests.Integration/ManagementApi/ManagementApiTest.cs +++ b/tests/Umbraco.Tests.Integration/ManagementApi/ManagementApiTest.cs @@ -125,9 +125,7 @@ protected async Task AuthenticateClientAsync(HttpClient client, Func Date: Tue, 24 Mar 2026 13:22:03 +0100 Subject: [PATCH 25/39] more cleanup --- .../Document/PatchDocumentController.cs | 2 +- .../Patchers/DocumentPatcher.cs | 5 ++--- .../Patching/PatchPathParserTests.cs | 14 +++++++------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs index 129d812f3394..513bea374d38 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs @@ -54,7 +54,7 @@ public async Task Patch( // Apply PATCH operations to create an update request model Attempt patchResult = - await _documentPatcher.ApplyPatchAsync(id, patchModel, CurrentUserKey(_backOfficeSecurityAccessor)); + await _documentPatcher.ApplyPatchAsync(id, patchModel); if (!patchResult.Success) { diff --git a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs index fe143e58da12..b495cd84e0c8 100644 --- a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs +++ b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs @@ -39,13 +39,12 @@ public DocumentPatcher( /// An attempt containing the update model or an error status. public async Task> ApplyPatchAsync( Guid documentKey, - ContentPatchModel patchModel, - Guid userKey) + ContentPatchModel patchModel) { // Validate operation structure foreach (PatchOperationModel operation in patchModel.Operations) { - if (!PatchPathParser.IsValid(operation.Path)) + if (!PatchPathParser.IsValid(operation.Path, out _)) { return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathParserTests.cs index 0a20157e8eae..dbfec555966b 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathParserTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathParserTests.cs @@ -137,31 +137,31 @@ public void Parse_FilterWithoutEquals_ThrowsFormatException() [Test] public void IsValid_ValidSimplePath_ReturnsTrue() { - Assert.That(PatchPathParser.IsValid("/name"), Is.True); + Assert.That(PatchPathParser.IsValid("/name", out _), Is.True); } [Test] public void IsValid_ValidFilterPath_ReturnsTrue() { - Assert.That(PatchPathParser.IsValid("/values[alias=title,culture=en-US,segment=null]/value"), Is.True); + Assert.That(PatchPathParser.IsValid("/values[alias=title,culture=en-US,segment=null]/value", out _), Is.True); } [Test] public void IsValid_ValidAppendPath_ReturnsTrue() { - Assert.That(PatchPathParser.IsValid("/contentData/-"), Is.True); + Assert.That(PatchPathParser.IsValid("/contentData/-", out _), Is.True); } [Test] public void IsValid_EmptyString_ReturnsFalse() { - Assert.That(PatchPathParser.IsValid(string.Empty), Is.False); + Assert.That(PatchPathParser.IsValid(string.Empty, out _), Is.False); } [Test] public void IsValid_InvalidSyntax_ReturnsFalse() { - Assert.That(PatchPathParser.IsValid("/values[alias=title"), Is.False); + Assert.That(PatchPathParser.IsValid("/values[alias=title", out _), Is.False); } [Test] @@ -305,7 +305,7 @@ public void Parse_EscapeSequenceInMultiSegmentPath_DecodesOnlyAffectedSegment() [Test] public void IsValid_PathWithEscapeSequence_ReturnsTrue() { - Assert.That(PatchPathParser.IsValid("/foo~1bar"), Is.True); - Assert.That(PatchPathParser.IsValid("/foo~0bar"), Is.True); + Assert.That(PatchPathParser.IsValid("/foo~1bar", out _), Is.True); + Assert.That(PatchPathParser.IsValid("/foo~0bar", out _), Is.True); } } From 03a743c165c0b5adeb09fd34f67c1bcc09004ab7 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 24 Mar 2026 15:53:25 +0100 Subject: [PATCH 26/39] =?UTF-8?q?Put=20AllowedValues=20on=20the=20correct?= =?UTF-8?q?=20property=20=F0=9F=99=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ViewModels/Document/PatchDocumentRequestModel.cs | 1 - .../ViewModels/Document/PatchOperationRequestModel.cs | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs index fca5a3ac8197..3900f8a7293e 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs @@ -13,6 +13,5 @@ public class PatchDocumentRequestModel /// [Required] [MinLength(1)] - [AllowedValues("add", "replace", "remove", ErrorMessage = "Supported operations are: add, replace, remove.")] public PatchOperationRequestModel[] Operations { get; set; } = Array.Empty(); } diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchOperationRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchOperationRequestModel.cs index 52d5514e05f3..bb491e490cb4 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchOperationRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchOperationRequestModel.cs @@ -11,6 +11,7 @@ public class PatchOperationRequestModel /// The operation type: "replace", "add", or "remove". /// [Required] + [AllowedValues("add", "replace", "remove", ErrorMessage = "Supported operations are: add, replace, remove.")] public string Op { get; set; } = string.Empty; /// From 8806113171f63905175ab487144a5524b382a330 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 25 Mar 2026 09:23:16 +0100 Subject: [PATCH 27/39] One more default implementation --- .../Factories/IDocumentEditingPresentationFactory.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs index 4beb829148b1..370fe573f0cd 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs @@ -24,13 +24,14 @@ public interface IDocumentEditingPresentationFactory /// The mapped content update model. ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel); + // TODO (V19): Remove the default implementation. /// /// Creates an from the given , /// mapping its properties, variants, and template into the request model representation. /// /// The content item to create the update request model from. /// An representing the content. - Task CreateUpdateRequestModelAsync(IContent content); + Task CreateUpdateRequestModelAsync(IContent content) => throw new NotImplementedException(); // TODO (V19): Remove the default implementation. /// From da1cacc8665a2c4fe8bd708439df7ab58cc75067 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 25 Mar 2026 10:27:35 +0100 Subject: [PATCH 28/39] Add link to docs on endpoint swagger info --- .../Controllers/Document/PatchDocumentController.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs index 513bea374d38..71cc99772b4f 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs @@ -43,6 +43,7 @@ public PatchDocumentController( [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + [EndpointSummary("Make partial updates to a document. For more information, see the documentation at https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document or https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-spec")] [Consumes("application/json-patch+json")] public async Task Patch( CancellationToken cancellationToken, From c398646a2fdf125d115b1aef7f768529a7aeff37 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Fri, 3 Apr 2026 14:08:26 +0200 Subject: [PATCH 29/39] PR review corrections - Removed leftover affectedCultures & affectedSegments - Extracted IDocumentPatcher interface - Optimized serialization in patchEngine by moving it 1 level higher --- .../Document/PatchDocumentController.cs | 4 ++-- .../DocumentBuilderExtensions.cs | 2 +- .../DocumentEditingPresentationFactory.cs | 14 ------------ .../Patchers/DocumentPatcher.cs | 22 ++++++++++++++++--- .../Patchers/IDocumentPatcher.cs | 21 ++++++++++++++++++ .../Patching/PatchEngine.cs | 20 ++++++----------- 6 files changed, 50 insertions(+), 33 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Patchers/IDocumentPatcher.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs index 71cc99772b4f..a0380e84c329 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs @@ -19,14 +19,14 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document; public class PatchDocumentController : PatchDocumentControllerBase { private readonly IContentEditingService _contentEditingService; - private readonly DocumentPatcher _documentPatcher; + private readonly IDocumentPatcher _documentPatcher; private readonly IDocumentEditingPresentationFactory _presentationFactory; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; public PatchDocumentController( IAuthorizationService authorizationService, IContentEditingService contentEditingService, - DocumentPatcher documentPatcher, + IDocumentPatcher documentPatcher, IDocumentEditingPresentationFactory presentationFactory, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) : base(authorizationService) diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs index 2e856fb03736..807572c4ae73 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs @@ -22,7 +22,7 @@ internal static IUmbracoBuilder AddDocuments(this IUmbracoBuilder builder) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); - builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.WithCollectionBuilder() .Add() diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs index 2b4a5e2e36d5..39463cd70b8a 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs @@ -80,23 +80,9 @@ public ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel) Value = op.Value, }).ToArray(); - var paths = operations.Select(o => o.Path).ToArray(); - - var affectedCultures = paths - .SelectMany(PatchPathParser.ExtractCultures) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - var affectedSegments = paths - .SelectMany(PatchPathParser.ExtractSegments) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - return new ContentPatchModel { Operations = operations, - AffectedCultures = affectedCultures, - AffectedSegments = affectedSegments, }; } diff --git a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs index b495cd84e0c8..238c181788de 100644 --- a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs +++ b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs @@ -1,3 +1,5 @@ +using System.Text.Json; +using System.Text.Json.Nodes; using Umbraco.Cms.Api.Management.Factories; using Umbraco.Cms.Api.Management.OperationStatus; using Umbraco.Cms.Api.Management.Patching; @@ -13,7 +15,7 @@ namespace Umbraco.Cms.Api.Management.Patchers; /// /// Applies patch operations with Umbraco's custom path syntax to document update models. /// -public class DocumentPatcher +public class DocumentPatcher : IDocumentPatcher { private readonly IContentEditingService _contentEditingService; private readonly IJsonSerializer _jsonSerializer; @@ -68,13 +70,25 @@ public async Task(currentJsonString); diff --git a/src/Umbraco.Cms.Api.Management/Patchers/IDocumentPatcher.cs b/src/Umbraco.Cms.Api.Management/Patchers/IDocumentPatcher.cs new file mode 100644 index 000000000000..4adb9895c85f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Patchers/IDocumentPatcher.cs @@ -0,0 +1,21 @@ +using Umbraco.Cms.Api.Management.OperationStatus; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Patching; +using Umbraco.Cms.Core; + +namespace Umbraco.Cms.Api.Management.Patchers; + +public interface IDocumentPatcher +{ + /// + /// Applies PATCH operations to a document and returns an update model. + /// Validates operations and returns appropriate error status if validation fails. + /// + /// The document key. + /// The patch model containing operations and affected cultures/segments. + /// The user performing the operation. + /// An attempt containing the update model or an error status. + Task> ApplyPatchAsync( + Guid documentKey, + ContentPatchModel patchModel); +} diff --git a/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs index 4f28883e7a4e..bc71103f7435 100644 --- a/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs @@ -11,27 +11,21 @@ namespace Umbraco.Cms.Api.Management.Patching; public static class PatchEngine { /// - /// Applies a single patch operation to a JSON string and returns the modified JSON. + /// Applies a single patch operation to a JSON node and returns the modified JSON node. /// - /// The JSON string to modify. + /// The JSON node to modify. /// The operation type (Replace, Add, Remove). /// The patch path expression. /// The value to set (required for Replace and Add operations). - /// The modified JSON string. + /// The modified JSON node. /// Thrown when is null or whitespace. /// Thrown when the operation cannot be applied. /// Thrown when the path syntax is invalid. - public static string ApplyOperation(string json, PatchOperationType op, string path, object? value) + public static JsonNode ApplyOperation(JsonNode jsonNode, PatchOperationType op, string path, object? value) { - if (string.IsNullOrWhiteSpace(json)) - { - throw new ArgumentNullException(nameof(json)); - } - PatchPathSegment[] segments = PatchPathParser.Parse(path); - JsonNode? rootNode = JsonNode.Parse(json); - if (rootNode is null) + if (jsonNode is null) { throw new InvalidOperationException("Failed to parse JSON string."); } @@ -40,10 +34,10 @@ public static string ApplyOperation(string json, PatchOperationType op, string p ? JsonSerializer.SerializeToNode(value) : null; - ResolvedTarget target = PatchPathResolver.Resolve(rootNode, segments); + ResolvedTarget target = PatchPathResolver.Resolve(jsonNode, segments); ApplyMutation(target, op, valueNode); - return rootNode.ToJsonString(); + return jsonNode; } private static void ApplyMutation(ResolvedTarget target, PatchOperationType op, JsonNode? valueNode) From c2b01b82b5ce7bdfb6256f506b5c3df22412b17a Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Fri, 3 Apr 2026 14:27:48 +0200 Subject: [PATCH 30/39] Update documentation urls --- .../Controllers/Document/PatchDocumentController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs index a0380e84c329..f013ad022b26 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs @@ -43,7 +43,7 @@ public PatchDocumentController( [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] - [EndpointSummary("Make partial updates to a document. For more information, see the documentation at https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document or https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-spec")] + [EndpointSummary("Make partial updates to a document. For more information, see the documentation at https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-endpoint-guide or https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-endpoint-spec")] [Consumes("application/json-patch+json")] public async Task Patch( CancellationToken cancellationToken, From 1cacb390759110b10aeefea8e0f63b23cf6123c4 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Sun, 5 Apr 2026 11:39:43 +0200 Subject: [PATCH 31/39] Apply suggestions from code review Co-authored-by: Andy Butland --- .../Controllers/Document/PatchDocumentController.cs | 2 +- .../DependencyInjection/DocumentBuilderExtensions.cs | 2 +- .../Patchers/DocumentPatcher.cs | 11 ++--------- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs index f013ad022b26..0cb80492d65a 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs @@ -57,7 +57,7 @@ public async Task Patch( Attempt patchResult = await _documentPatcher.ApplyPatchAsync(id, patchModel); - if (!patchResult.Success) + if (patchResult.Success is false) { return ContentPatchingOperationStatusResult(patchResult.Status); } diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs index 807572c4ae73..a9ee4d0b6c14 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/DocumentBuilderExtensions.cs @@ -22,7 +22,7 @@ internal static IUmbracoBuilder AddDocuments(this IUmbracoBuilder builder) builder.Services.AddTransient(); builder.Services.AddTransient(); builder.Services.AddTransient(); - builder.Services.AddTransient(); + builder.Services.AddTransient(); builder.WithCollectionBuilder() .Add() diff --git a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs index 238c181788de..eb168dd10b5d 100644 --- a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs +++ b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs @@ -31,14 +31,7 @@ public DocumentPatcher( _documentEditingPresentationFactory = documentEditingPresentationFactory; } - /// - /// Applies PATCH operations to a document and returns an update model. - /// Validates operations and returns appropriate error status if validation fails. - /// - /// The document key. - /// The patch model containing operations and affected cultures/segments. - /// The user performing the operation. - /// An attempt containing the update model or an error status. + /// public async Task> ApplyPatchAsync( Guid documentKey, ContentPatchModel patchModel) @@ -79,7 +72,7 @@ public async Task Date: Sun, 5 Apr 2026 11:43:04 +0200 Subject: [PATCH 32/39] Removed affected variance tracking that is nog longer being used --- .../ViewModels/Patching/ContentPatchModel.cs | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Patching/ContentPatchModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Patching/ContentPatchModel.cs index 917d0ca5fb77..3b1063066090 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Patching/ContentPatchModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Patching/ContentPatchModel.cs @@ -8,17 +8,5 @@ public class ContentPatchModel /// /// Gets or sets collection of PATCH operations to apply. /// - public PatchOperationModel[] Operations { get; set; } = Array.Empty(); - - /// - /// Gets or sets cultures explicitly affected by this patch (extracted from operation paths). - /// Used for authorization checks. - /// - public IEnumerable AffectedCultures { get; set; } = Array.Empty(); - - /// - /// Gets or sets segments explicitly affected by this patch (extracted from operation paths). - /// - public IEnumerable AffectedSegments { get; set; } = Array.Empty(); - + public PatchOperationModel[] Operations { get; set; } = []; } From ba217678003972bffec42d7d79bc27ebce1b5974 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Sun, 5 Apr 2026 11:46:07 +0200 Subject: [PATCH 33/39] Extract shared data class --- .../Patching/PatchPathResolver.cs | 28 ----------------- .../Patching/ResolvedTarget.cs | 31 +++++++++++++++++++ 2 files changed, 31 insertions(+), 28 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/Patching/ResolvedTarget.cs diff --git a/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs index 69b095bb7372..f11f8a4637fc 100644 --- a/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchPathResolver.cs @@ -2,34 +2,6 @@ namespace Umbraco.Cms.Api.Management.Patching; -/// -/// The result of resolving a patch path against a JSON document. -/// -public sealed class ResolvedTarget -{ - /// - /// The parent node of the target location. - /// - public required JsonNode Parent { get; init; } - - /// - /// The key identifying the target within the parent: - /// a for object properties, an for array indices, - /// or null for append operations. - /// - public required object? Key { get; init; } - - /// - /// The current value at the target location, or null if the location doesn't exist yet (e.g., for Add). - /// - public JsonNode? Current { get; init; } - - /// - /// Whether this target represents an append to the end of an array. - /// - public bool IsAppend { get; init; } -} - /// /// Resolves parsed patch path segments against a JSON document to find the target node. /// diff --git a/src/Umbraco.Cms.Api.Management/Patching/ResolvedTarget.cs b/src/Umbraco.Cms.Api.Management/Patching/ResolvedTarget.cs new file mode 100644 index 000000000000..e397a536f69b --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Patching/ResolvedTarget.cs @@ -0,0 +1,31 @@ +using System.Text.Json.Nodes; + +namespace Umbraco.Cms.Api.Management.Patching; + +/// +/// The result of resolving a patch path against a JSON document. +/// +public sealed class ResolvedTarget +{ + /// + /// The parent node of the target location. + /// + public required JsonNode Parent { get; init; } + + /// + /// The key identifying the target within the parent: + /// a for object properties, an for array indices, + /// or null for append operations. + /// + public required object? Key { get; init; } + + /// + /// The current value at the target location, or null if the location doesn't exist yet (e.g., for Add). + /// + public JsonNode? Current { get; init; } + + /// + /// Whether this target represents an append to the end of an array. + /// + public bool IsAppend { get; init; } +} From 202595119065547386a5b01c97f97dd3d54e82bc Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Sun, 5 Apr 2026 11:46:15 +0200 Subject: [PATCH 34/39] update claude patching namespace --- src/Umbraco.Cms.Api.Management/CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Umbraco.Cms.Api.Management/CLAUDE.md b/src/Umbraco.Cms.Api.Management/CLAUDE.md index 2151bb1c9345..72a78f32df72 100644 --- a/src/Umbraco.Cms.Api.Management/CLAUDE.md +++ b/src/Umbraco.Cms.Api.Management/CLAUDE.md @@ -26,7 +26,7 @@ RESTful API for Umbraco backoffice operations. Manages content, media, users, an - **Validation**: FluentValidation via base controllers - **Serialization**: System.Text.Json with custom converters - **Mapping**: Manual presentation factories (no AutoMapper) -- **Patching**: Custom patch engine for PATCH operations (Umbraco.Core.PropertyEditors.Patching) +- **Patching**: Custom patch engine for PATCH operations (Umbraco.Cms.Api.Management.Patching) - ⚠️ Legacy JsonPatch.Net support (IJsonPatchService) still available but **obsolete** - scheduled for removal in v19 - **Real-time**: SignalR hubs (`BackofficeHub`, `ServerEventHub`) - **DI**: Microsoft.Extensions.DependencyInjection via `ManagementApiComposer` From 33f1ef19041ef046411552537740ee6590eff7ab Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Sun, 5 Apr 2026 11:46:59 +0200 Subject: [PATCH 35/39] Remove no longer valid xml comment --- src/Umbraco.Cms.Api.Management/Patchers/IDocumentPatcher.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Umbraco.Cms.Api.Management/Patchers/IDocumentPatcher.cs b/src/Umbraco.Cms.Api.Management/Patchers/IDocumentPatcher.cs index 4adb9895c85f..951dd4acf5fe 100644 --- a/src/Umbraco.Cms.Api.Management/Patchers/IDocumentPatcher.cs +++ b/src/Umbraco.Cms.Api.Management/Patchers/IDocumentPatcher.cs @@ -13,7 +13,6 @@ public interface IDocumentPatcher /// /// The document key. /// The patch model containing operations and affected cultures/segments. - /// The user performing the operation. /// An attempt containing the update model or an error status. Task> ApplyPatchAsync( Guid documentKey, From 7e1111fa0305d6c260b794bc903f7b799012c8b0 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Sun, 5 Apr 2026 11:55:56 +0200 Subject: [PATCH 36/39] Fix unittests after refactoring patchengine.ApplyOperation(string,...) to patchengine.ApplyOperation(JsonNode,...) --- .../Patching/PatchEngineTests.cs | 75 ++++++++++--------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchEngineTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchEngineTests.cs index e32ced3838cc..0248b0cbab70 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchEngineTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchEngineTests.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using System.Text.Json.Nodes; +using Json.More; using NUnit.Framework; using Umbraco.Cms.Api.Management.Patching; using Umbraco.Cms.Api.Management.ViewModels.Patching; @@ -18,9 +20,9 @@ public void Replace_SimpleProperty_UpdatesValue() } """; - var result = PatchEngine.ApplyOperation(json, PatchOperationType.Replace, "/name", "Updated Name"); + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Replace, "/name", "Updated Name"); - var doc = JsonDocument.Parse(result); + var doc = result.ToJsonDocument(); Assert.That(doc.RootElement.GetProperty("name").GetString(), Is.EqualTo("Updated Name")); Assert.That(doc.RootElement.GetProperty("title").GetString(), Is.EqualTo("Original Title")); } @@ -38,11 +40,11 @@ public void Replace_ArrayElementProperty_UpdatesValue() """; var result = PatchEngine.ApplyOperation( - json, PatchOperationType.Replace, + JsonNode.Parse(json)!, PatchOperationType.Replace, "/values[alias=title]/value", "Updated Value"); - var doc = JsonDocument.Parse(result); + var doc = result.ToJsonDocument(); var values = doc.RootElement.GetProperty("values").EnumerateArray().ToList(); Assert.That(values[0].GetProperty("value").GetString(), Is.EqualTo("Updated Value")); Assert.That(values[1].GetProperty("value").GetString(), Is.EqualTo("Description Value")); @@ -68,12 +70,12 @@ public void Replace_NestedProperty_UpdatesValue() """; var result = PatchEngine.ApplyOperation( - json, + JsonNode.Parse(json)!, PatchOperationType.Replace, "/values[alias=contentBlocks]/value/contentData[key=block-2]/values[alias=headline]/value", "Updated Block 2 Headline"); - var doc = JsonDocument.Parse(result); + var doc = result.ToJsonDocument(); var contentBlocks = doc.RootElement.GetProperty("values").EnumerateArray().First(); var contentData = contentBlocks.GetProperty("value").GetProperty("contentData").EnumerateArray().ToList(); var block2 = contentData.First(b => b.GetProperty("key").GetString() == "block-2"); @@ -108,12 +110,12 @@ public void Replace_NullFilters_UpdatesValue() """; var result = PatchEngine.ApplyOperation( - json, + JsonNode.Parse(json)!, PatchOperationType.Replace, "/values[alias=contentBlocks,culture=null,segment=null]/value/contentData[key=block-2]/values[alias=headline]/value", "Updated Block 2 Headline"); - var doc = JsonDocument.Parse(result); + var doc = result.ToJsonDocument(); var contentBlocks = doc.RootElement.GetProperty("values").EnumerateArray().First(); var contentData = contentBlocks.GetProperty("value").GetProperty("contentData").EnumerateArray().ToList(); @@ -192,9 +194,9 @@ public void Replace_RealBlockListStructure_UpdatesValue() var path = $"/values[alias=contentBlocks,culture=null,segment=null]/value/contentData[key={blockKey}]/values[alias=headline]/value"; - var result = PatchEngine.ApplyOperation(json, PatchOperationType.Replace, path, "Updated Block 2 Headline"); + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Replace, path, "Updated Block 2 Headline"); - var doc = JsonDocument.Parse(result); + var doc = result.ToJsonDocument(); var values = doc.RootElement.GetProperty("values").EnumerateArray().First(); var contentData = values.GetProperty("value").GetProperty("contentData").EnumerateArray().ToList(); @@ -221,9 +223,9 @@ public void Add_AppendToArray_AddsElementAtEnd() var newBlock = new { key = "block-2", value = "second" }; - var result = PatchEngine.ApplyOperation(json, PatchOperationType.Add, "/contentData/-", newBlock); + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Add, "/contentData/-", newBlock); - var doc = JsonDocument.Parse(result); + var doc = result.ToJsonDocument(); var contentData = doc.RootElement.GetProperty("contentData").EnumerateArray().ToList(); Assert.That(contentData, Has.Count.EqualTo(2)); Assert.That(contentData[0].GetProperty("key").GetString(), Is.EqualTo("block-1")); @@ -239,9 +241,9 @@ public void Add_InsertAtIndex_InsertsElement() } """; - var result = PatchEngine.ApplyOperation(json, PatchOperationType.Add, "/items/1", "inserted"); + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Add, "/items/1", "inserted"); - var doc = JsonDocument.Parse(result); + var doc = result.ToJsonDocument(); var items = doc.RootElement.GetProperty("items").EnumerateArray().Select(e => e.GetString()).ToList(); Assert.That(items, Is.EqualTo(new[] { "a", "inserted", "b", "c" })); } @@ -255,9 +257,9 @@ public void Add_NewObjectProperty_AddsProperty() } """; - var result = PatchEngine.ApplyOperation(json, PatchOperationType.Add, "/description", "New Description"); + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Add, "/description", "New Description"); - var doc = JsonDocument.Parse(result); + var doc = result.ToJsonDocument(); Assert.That(doc.RootElement.GetProperty("name").GetString(), Is.EqualTo("Test")); Assert.That(doc.RootElement.GetProperty("description").GetString(), Is.EqualTo("New Description")); } @@ -285,12 +287,12 @@ public void Add_AppendToNestedArray_AddsElement() var newBlock = new { key = "block-2", values = Array.Empty() }; var result = PatchEngine.ApplyOperation( - json, + JsonNode.Parse(json)!, PatchOperationType.Add, "/values[alias=contentBlocks,culture=null,segment=null]/value/contentData/-", newBlock); - var doc = JsonDocument.Parse(result); + var doc = result.ToJsonDocument(); var contentData = doc.RootElement .GetProperty("values").EnumerateArray().First() .GetProperty("value") @@ -311,9 +313,9 @@ public void Remove_ArrayElementByFilter_RemovesElement() } """; - var result = PatchEngine.ApplyOperation(json, PatchOperationType.Remove, "/values[alias=title]", null); + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Remove, "/values[alias=title]", null); - var doc = JsonDocument.Parse(result); + var doc = result.ToJsonDocument(); var values = doc.RootElement.GetProperty("values").EnumerateArray().ToList(); Assert.That(values, Has.Count.EqualTo(1)); Assert.That(values[0].GetProperty("alias").GetString(), Is.EqualTo("description")); @@ -328,9 +330,9 @@ public void Remove_ArrayElementByIndex_RemovesElement() } """; - var result = PatchEngine.ApplyOperation(json, PatchOperationType.Remove, "/items/1", null); + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Remove, "/items/1", null); - var doc = JsonDocument.Parse(result); + var doc = result.ToJsonDocument(); var items = doc.RootElement.GetProperty("items").EnumerateArray().Select(e => e.GetString()).ToList(); Assert.That(items, Is.EqualTo(new[] { "a", "c" })); } @@ -345,9 +347,9 @@ public void Remove_ObjectProperty_RemovesProperty() } """; - var result = PatchEngine.ApplyOperation(json, PatchOperationType.Remove, "/description", null); + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Remove, "/description", null); - var doc = JsonDocument.Parse(result); + var doc = result.ToJsonDocument(); Assert.That(doc.RootElement.GetProperty("name").GetString(), Is.EqualTo("Test")); Assert.That(doc.RootElement.TryGetProperty("description", out _), Is.False); } @@ -362,7 +364,7 @@ public void Replace_WithAppendSegment_ThrowsInvalidOperationException() """; Assert.Throws(() => - PatchEngine.ApplyOperation(json, PatchOperationType.Replace, "/items/-", "new")); + PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Replace, "/items/-", "new")); } [Test] @@ -375,7 +377,7 @@ public void Remove_WithAppendSegment_ThrowsInvalidOperationException() """; Assert.Throws(() => - PatchEngine.ApplyOperation(json, PatchOperationType.Remove, "/items/-", null)); + PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Remove, "/items/-", null)); } [Test] @@ -384,13 +386,13 @@ public void ApplyOperation_InvalidPath_ThrowsFormatException() var json = """{ "name": "test" }"""; Assert.Throws(() => - PatchEngine.ApplyOperation(json, PatchOperationType.Replace, "name", "new")); + PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Replace, "name", "new")); } [Test] public void ApplyOperation_NullJson_ThrowsArgumentNullException() { - Assert.Throws(() => + Assert.Throws(() => PatchEngine.ApplyOperation(null!, PatchOperationType.Replace, "/name", "new")); } @@ -419,13 +421,12 @@ public void Replace_OutputCanBeDeserialized() """; var result = PatchEngine.ApplyOperation( - json, + JsonNode.Parse(json)!, PatchOperationType.Replace, "/values[alias=contentBlocks,culture=null,segment=null]/value/contentData[key=block-2]/values[alias=headline]/value", "Updated Block 2"); - // Verify the result is valid JSON - var doc = JsonDocument.Parse(result); + var doc = result.ToJsonDocument(); // Extract the value property (simulates what JsonObjectConverter returns) var valueElement = doc.RootElement.GetProperty("values").EnumerateArray().First().GetProperty("value"); @@ -458,12 +459,12 @@ public void Replace_MultipleFilterConditions_MatchesCorrectElement() """; var result = PatchEngine.ApplyOperation( - json, + JsonNode.Parse(json)!, PatchOperationType.Replace, "/values[alias=price,culture=en-US,segment=premium]/value", "200"); - var doc = JsonDocument.Parse(result); + var doc = result.ToJsonDocument(); var values = doc.RootElement.GetProperty("values").EnumerateArray().ToList(); Assert.That(values[0].GetProperty("value").GetString(), Is.EqualTo("100")); Assert.That(values[1].GetProperty("value").GetString(), Is.EqualTo("200")); @@ -481,9 +482,9 @@ public void Replace_PropertyNameContainingSlash_RequiresEscaping() } """; - var result = PatchEngine.ApplyOperation(json, PatchOperationType.Replace, "/a~1b", "updated"); + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Replace, "/a~1b", "updated"); - var doc = JsonDocument.Parse(result); + var doc = result.ToJsonDocument(); Assert.That(doc.RootElement.GetProperty("a/b").GetString(), Is.EqualTo("updated")); } @@ -496,9 +497,9 @@ public void Replace_PropertyNameContainingTilde_RequiresEscaping() } """; - var result = PatchEngine.ApplyOperation(json, PatchOperationType.Replace, "/a~0b", "updated"); + var result = PatchEngine.ApplyOperation(JsonNode.Parse(json)!, PatchOperationType.Replace, "/a~0b", "updated"); - var doc = JsonDocument.Parse(result); + var doc = result.ToJsonDocument(); Assert.That(doc.RootElement.GetProperty("a~b").GetString(), Is.EqualTo("updated")); } } From e8c55ad948bb9ed3a0b029a64ab7c64cebad12e8 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Tue, 7 Apr 2026 09:37:52 +0200 Subject: [PATCH 37/39] Refactor base classes --- .../Document/PatchDocumentController.cs | 4 +-- .../Document/PatchDocumentControllerBase.cs | 30 ++----------------- .../Document/UpdateDocumentControllerBase.cs | 3 ++ 3 files changed, 8 insertions(+), 29 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs index 0cb80492d65a..fd525d4f719f 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentController.cs @@ -49,7 +49,7 @@ public async Task Patch( CancellationToken cancellationToken, Guid id, PatchDocumentRequestModel requestModel) - => await HandleRequest(id, requestModel, async () => + => await HandleRequest(id, async () => { ContentPatchModel patchModel = _presentationFactory.MapPatchModel(requestModel); @@ -57,7 +57,7 @@ public async Task Patch( Attempt patchResult = await _documentPatcher.ApplyPatchAsync(id, patchModel); - if (patchResult.Success is false) + if (patchResult.Success is false) { return ContentPatchingOperationStatusResult(patchResult.Status); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs index cea4b6d0bb1f..342157e9080e 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PatchDocumentControllerBase.cs @@ -1,39 +1,15 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Api.Management.OperationStatus; -using Umbraco.Cms.Api.Management.ViewModels.Document; -using Umbraco.Cms.Core.Actions; -using Umbraco.Cms.Core.Security.Authorization; -using Umbraco.Cms.Web.Common.Authorization; -using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Controllers.Document; -public abstract class PatchDocumentControllerBase : DocumentControllerBase +public abstract class PatchDocumentControllerBase : UpdateDocumentControllerBase { - private readonly IAuthorizationService _authorizationService; - protected PatchDocumentControllerBase(IAuthorizationService authorizationService) - => _authorizationService = authorizationService; - - protected async Task HandleRequest(Guid id, PatchDocumentRequestModel requestModel, Func> authorizedHandler) + : base(authorizationService) { - // We intentionally don't pass in cultures here. - // This is to support the client sending values for all cultures even if the user doesn't have access to the language. - // Values for unauthorized languages are later ignored in the ContentEditingService. - AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( - User, - ContentPermissionResource.WithKeys(ActionUpdate.ActionLetter, id), - AuthorizationPolicies.ContentPermissionByResource); - - if (authorizationResult.Succeeded is false) - { - return Forbidden(); - } - - return await authorizedHandler(); } /// @@ -52,6 +28,6 @@ protected IActionResult ContentPatchingOperationStatusResult(ContentPatchingOper _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder .WithTitle("Unknown error") .WithDetail("An unexpected error occurred during the PATCH operation.") - .Build()) + .Build()), }); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentControllerBase.cs index b3e6d99976e3..5210c9819b11 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentControllerBase.cs @@ -20,6 +20,9 @@ protected UpdateDocumentControllerBase(IAuthorizationService authorizationServic => _authorizationService = authorizationService; protected async Task HandleRequest(Guid id, UpdateDocumentRequestModel requestModel, Func> authorizedHandler) + => await HandleRequest(id, authorizedHandler); + + protected async Task HandleRequest(Guid id, Func> authorizedHandler) { // We intentionally don't pass in cultures here. // This is to support the client sending values for all cultures even if the user doesn't have access to the language. From 93a439b060e1a003f52be8ec787568e33d50f398 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 8 Apr 2026 09:42:26 +0200 Subject: [PATCH 38/39] Apply suggestions from code review Co-authored-by: Andy Butland --- .../JsonBuilderExtensions.cs | 2 +- .../Patchers/DocumentPatcher.cs | 224 +++++++++--------- .../Patching/PatchEngine.cs | 1 - .../Patching/PatchPathParser.cs | 5 +- .../Services/IJsonPatchService.cs | 2 +- .../Services/JsonPatchService.cs | 2 +- .../Document/PatchDocumentRequestModel.cs | 2 +- 7 files changed, 118 insertions(+), 120 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs b/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs index 54710d139695..74566d960538 100644 --- a/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Management/DependencyInjection/JsonBuilderExtensions.cs @@ -7,7 +7,7 @@ namespace Umbraco.Cms.Api.Management.DependencyInjection; /// /// Extension methods for registering JSON-related services. /// -[Obsolete("JsonPatch.Net dependency and IJsonPatchService are being removed. Use the custom patch engine (DocumentPatcher) instead. Scheduled for removal in Umbraco 19.")] +[Obsolete("JsonPatch.Net dependency and IJsonPatchService are being removed. Use the custom patch engine (IDocumentPatcher) instead. Scheduled for removal in Umbraco 19.")] public static class JsonBuilderExtensions { /// diff --git a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs index eb168dd10b5d..542b457eb439 100644 --- a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs +++ b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs @@ -1,114 +1,112 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using Umbraco.Cms.Api.Management.Factories; -using Umbraco.Cms.Api.Management.OperationStatus; -using Umbraco.Cms.Api.Management.Patching; -using Umbraco.Cms.Api.Management.ViewModels.Document; -using Umbraco.Cms.Api.Management.ViewModels.Patching; -using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Serialization; -using Umbraco.Cms.Core.Services; - -namespace Umbraco.Cms.Api.Management.Patchers; - -/// -/// Applies patch operations with Umbraco's custom path syntax to document update models. -/// -public class DocumentPatcher : IDocumentPatcher -{ - private readonly IContentEditingService _contentEditingService; - private readonly IJsonSerializer _jsonSerializer; - private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; - - public DocumentPatcher( - IContentEditingService contentEditingService, - IJsonSerializer jsonSerializer, - IDocumentEditingPresentationFactory documentEditingPresentationFactory) - { - _contentEditingService = contentEditingService; - _jsonSerializer = jsonSerializer; - _documentEditingPresentationFactory = documentEditingPresentationFactory; - } - +using System.Text.Json; +using System.Text.Json.Nodes; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.OperationStatus; +using Umbraco.Cms.Api.Management.Patching; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Patching; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Patchers; + +/// +/// Applies patch operations with Umbraco's custom path syntax to document update models. +/// +public class DocumentPatcher : IDocumentPatcher +{ + private readonly IContentEditingService _contentEditingService; + private readonly IJsonSerializer _jsonSerializer; + private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; + + public DocumentPatcher( + IContentEditingService contentEditingService, + IJsonSerializer jsonSerializer, + IDocumentEditingPresentationFactory documentEditingPresentationFactory) + { + _contentEditingService = contentEditingService; + _jsonSerializer = jsonSerializer; + _documentEditingPresentationFactory = documentEditingPresentationFactory; + } + /// - public async Task> ApplyPatchAsync( - Guid documentKey, - ContentPatchModel patchModel) - { - // Validate operation structure - foreach (PatchOperationModel operation in patchModel.Operations) - { - if (!PatchPathParser.IsValid(operation.Path, out _)) - { - return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); - } - - // Validate that replace/add operations have a value - if ((operation.Op == PatchOperationType.Replace || operation.Op == PatchOperationType.Add) && - operation.Value is null) - { - return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); - } - } - - // Load the content - IContent? content = await _contentEditingService.GetAsync(documentKey); - if (content is null) - { - return Attempt.FailWithStatus(ContentPatchingOperationStatus.NotFound, default(UpdateDocumentRequestModel)!); - } - - // Convert to JSON as if a client would have sent the full payload - UpdateDocumentRequestModel unModifiedUpdateModel = await _documentEditingPresentationFactory.CreateUpdateRequestModelAsync(content); - var currentJsonString = _jsonSerializer.Serialize(unModifiedUpdateModel); - - if (string.IsNullOrWhiteSpace(currentJsonString)) - { - // should not happen as the content exists. - throw new JsonException("Unexpected empty JSON string when building update model for patching."); - } - var currentJsonNode = JsonNode.Parse(currentJsonString); - if (currentJsonNode is null) - { - // should not happen as string is a result of jsonSerialization. - throw new JsonException("Could not parse JSON string to JsonNode when building update model for patching."); - } - - // Apply each PATCH operation to the JSON - foreach (PatchOperationModel operation in patchModel.Operations) - { - try - { - currentJsonNode = PatchEngine.ApplyOperation( - currentJsonNode, - operation.Op, - operation.Path, - operation.Value); - } - catch (InvalidOperationException) - { - // Path resolution failed or operation error - return Attempt.FailWithStatus( - ContentPatchingOperationStatus.InvalidOperation, - default(UpdateDocumentRequestModel)!); - } - catch (FormatException) - { - // Path syntax error - return Attempt.FailWithStatus( - ContentPatchingOperationStatus.InvalidOperation, - default(UpdateDocumentRequestModel)!); - } - } - - currentJsonString = currentJsonNode.ToJsonString(); - - // Deserialize the modified JSON back to UpdateDocumentRequestModel - UpdateDocumentRequestModel? modifiedUpdateModel = _jsonSerializer.Deserialize(currentJsonString); - - return modifiedUpdateModel is not null - ? Attempt.SucceedWithStatus(ContentPatchingOperationStatus.Success, modifiedUpdateModel) - : Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); - } -} + public async Task> ApplyPatchAsync( + Guid documentKey, + ContentPatchModel patchModel) + { + // Validate operation structure + foreach (PatchOperationModel operation in patchModel.Operations) + { + if (PatchPathParser.IsValid(operation.Path, out _) is false) + { + return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); + } + + // Validate that replace/add operations have a value + if ((operation.Op == PatchOperationType.Replace || operation.Op == PatchOperationType.Add) && + operation.Value is null) + { + return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); + } + } + + // Load the content + IContent? content = await _contentEditingService.GetAsync(documentKey); + if (content is null) + { + return Attempt.FailWithStatus(ContentPatchingOperationStatus.NotFound, default(UpdateDocumentRequestModel)!); + } + + // Convert to JSON as if a client would have sent the full payload + UpdateDocumentRequestModel unModifiedUpdateModel = await _documentEditingPresentationFactory.CreateUpdateRequestModelAsync(content); + var currentJsonString = _jsonSerializer.Serialize(unModifiedUpdateModel); + + if (string.IsNullOrWhiteSpace(currentJsonString)) + { + // should not happen as the content exists. + throw new JsonException("Unexpected empty JSON string when building update model for patching."); + } + + // Should not fail parsing as the string is a result of JSON serialization. + JsonNode currentJsonNode = JsonNode.Parse(currentJsonString) + ?? throw new JsonException("Could not parse JSON string to JsonNode when building update model for patching."); + + // Apply each PATCH operation to the JSON + foreach (PatchOperationModel operation in patchModel.Operations) + { + try + { + currentJsonNode = PatchEngine.ApplyOperation( + currentJsonNode, + operation.Op, + operation.Path, + operation.Value); + } + catch (InvalidOperationException) + { + // Path resolution failed or operation error + return Attempt.FailWithStatus( + ContentPatchingOperationStatus.InvalidOperation, + default(UpdateDocumentRequestModel)!); + } + catch (FormatException) + { + // Path syntax error + return Attempt.FailWithStatus( + ContentPatchingOperationStatus.InvalidOperation, + default(UpdateDocumentRequestModel)!); + } + } + + currentJsonString = currentJsonNode.ToJsonString(); + + // Deserialize the modified JSON back to UpdateDocumentRequestModel + UpdateDocumentRequestModel? modifiedUpdateModel = _jsonSerializer.Deserialize(currentJsonString); + + return modifiedUpdateModel is not null + ? Attempt.SucceedWithStatus(ContentPatchingOperationStatus.Success, modifiedUpdateModel) + : Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs index bc71103f7435..1c8aa52631a4 100644 --- a/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs @@ -18,7 +18,6 @@ public static class PatchEngine /// The patch path expression. /// The value to set (required for Replace and Add operations). /// The modified JSON node. - /// Thrown when is null or whitespace. /// Thrown when the operation cannot be applied. /// Thrown when the path syntax is invalid. public static JsonNode ApplyOperation(JsonNode jsonNode, PatchOperationType op, string path, object? value) diff --git a/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs index 5aa262c6b3a0..75cbdad396f4 100644 --- a/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs @@ -38,7 +38,7 @@ public static PatchPathSegment[] Parse(string path) throw new FormatException("Path cannot be null or empty."); } - if (!path.StartsWith('/')) + if (path.StartsWith('/') is false) { throw new FormatException("Path must start with '/'."); } @@ -111,7 +111,8 @@ public static PatchPathSegment[] Parse(string path) /// /// Validates that a path string is syntactically correct. /// - /// The path expression to validate. + /// The path expression to validate. + /// The parsed patch path segments returned in an out parameter. /// True if the path is valid, false otherwise. public static bool IsValid(string path, [NotNullWhen(true)]out PatchPathSegment[]? parsedPath) { diff --git a/src/Umbraco.Cms.Api.Management/Services/IJsonPatchService.cs b/src/Umbraco.Cms.Api.Management/Services/IJsonPatchService.cs index 225f432a8c41..cac5fead8166 100644 --- a/src/Umbraco.Cms.Api.Management/Services/IJsonPatchService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/IJsonPatchService.cs @@ -6,7 +6,7 @@ namespace Umbraco.Cms.Api.Management.Services; /// /// Represents a service that processes and applies JSON Patch operations to resources. /// -[Obsolete("Use the custom patch engine (DocumentPatcher) instead. JsonPatch.Net dependency is being removed. Scheduled for removal in Umbraco 19.")] +[Obsolete("Use the custom patch engine (IDocumentPatcher) instead. JsonPatch.Net dependency is being removed. Scheduled for removal in Umbraco 19.")] public interface IJsonPatchService { /// diff --git a/src/Umbraco.Cms.Api.Management/Services/JsonPatchService.cs b/src/Umbraco.Cms.Api.Management/Services/JsonPatchService.cs index 8b60d7b4da2d..045f0f58a838 100644 --- a/src/Umbraco.Cms.Api.Management/Services/JsonPatchService.cs +++ b/src/Umbraco.Cms.Api.Management/Services/JsonPatchService.cs @@ -8,7 +8,7 @@ namespace Umbraco.Cms.Api.Management.Services; /// /// Provides functionality to apply and manage JSON Patch operations on resources. /// -[Obsolete("Use the custom patch engine (DocumentPatcher) instead. JsonPatch.Net dependency is being removed. Scheduled for removal in Umbraco 19.")] +[Obsolete("Use the custom patch engine (IDocumentPatcher) instead. JsonPatch.Net dependency is being removed. Scheduled for removal in Umbraco 19.")] public class JsonPatchService : IJsonPatchService { private readonly IJsonSerializer _jsonSerializer; diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs index 3900f8a7293e..1286d23f91ed 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/PatchDocumentRequestModel.cs @@ -13,5 +13,5 @@ public class PatchDocumentRequestModel /// [Required] [MinLength(1)] - public PatchOperationRequestModel[] Operations { get; set; } = Array.Empty(); + public PatchOperationRequestModel[] Operations { get; set; } = []; } From cf5043c69ab96d57713462b9fec7dcec54b871ca Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Wed, 8 Apr 2026 09:58:06 +0200 Subject: [PATCH 39/39] Optimizations and refactoring of the patcher/engine/parser --- .../IDocumentEditingPresentationFactory.cs | 3 +- .../Patchers/DocumentPatcher.cs | 226 +++++++++--------- .../Patching/PatchEngine.cs | 18 +- .../Patching/PatchPathParser.cs | 92 +------ .../Patching/PatchOperationModel.cs | 7 + .../Patching/PatchPathParserTests.cs | 82 ------- 6 files changed, 141 insertions(+), 287 deletions(-) diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs index 370fe573f0cd..a502352319e9 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs @@ -42,11 +42,10 @@ public interface IDocumentEditingPresentationFactory /// A containing the mapped operations and affected cultures/segments. ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel) => throw new NotImplementedException(); - // TODO (V19): Remove the default implementation. /// /// Maps a to a for update validation. /// /// The validate update document request model. /// A ready for validation. - ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel) => throw new NotImplementedException(); + ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel); } diff --git a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs index 542b457eb439..dba24004431b 100644 --- a/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs +++ b/src/Umbraco.Cms.Api.Management/Patchers/DocumentPatcher.cs @@ -1,112 +1,114 @@ -using System.Text.Json; -using System.Text.Json.Nodes; -using Umbraco.Cms.Api.Management.Factories; -using Umbraco.Cms.Api.Management.OperationStatus; -using Umbraco.Cms.Api.Management.Patching; -using Umbraco.Cms.Api.Management.ViewModels.Document; -using Umbraco.Cms.Api.Management.ViewModels.Patching; -using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Serialization; -using Umbraco.Cms.Core.Services; - -namespace Umbraco.Cms.Api.Management.Patchers; - -/// -/// Applies patch operations with Umbraco's custom path syntax to document update models. -/// -public class DocumentPatcher : IDocumentPatcher -{ - private readonly IContentEditingService _contentEditingService; - private readonly IJsonSerializer _jsonSerializer; - private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; - - public DocumentPatcher( - IContentEditingService contentEditingService, - IJsonSerializer jsonSerializer, - IDocumentEditingPresentationFactory documentEditingPresentationFactory) - { - _contentEditingService = contentEditingService; - _jsonSerializer = jsonSerializer; - _documentEditingPresentationFactory = documentEditingPresentationFactory; - } - - /// - public async Task> ApplyPatchAsync( - Guid documentKey, - ContentPatchModel patchModel) - { - // Validate operation structure - foreach (PatchOperationModel operation in patchModel.Operations) - { - if (PatchPathParser.IsValid(operation.Path, out _) is false) - { - return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); - } - - // Validate that replace/add operations have a value - if ((operation.Op == PatchOperationType.Replace || operation.Op == PatchOperationType.Add) && - operation.Value is null) - { - return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); - } - } - - // Load the content - IContent? content = await _contentEditingService.GetAsync(documentKey); - if (content is null) - { - return Attempt.FailWithStatus(ContentPatchingOperationStatus.NotFound, default(UpdateDocumentRequestModel)!); - } - - // Convert to JSON as if a client would have sent the full payload - UpdateDocumentRequestModel unModifiedUpdateModel = await _documentEditingPresentationFactory.CreateUpdateRequestModelAsync(content); - var currentJsonString = _jsonSerializer.Serialize(unModifiedUpdateModel); - - if (string.IsNullOrWhiteSpace(currentJsonString)) - { - // should not happen as the content exists. - throw new JsonException("Unexpected empty JSON string when building update model for patching."); - } - - // Should not fail parsing as the string is a result of JSON serialization. - JsonNode currentJsonNode = JsonNode.Parse(currentJsonString) - ?? throw new JsonException("Could not parse JSON string to JsonNode when building update model for patching."); - - // Apply each PATCH operation to the JSON - foreach (PatchOperationModel operation in patchModel.Operations) - { - try - { - currentJsonNode = PatchEngine.ApplyOperation( - currentJsonNode, - operation.Op, - operation.Path, - operation.Value); - } - catch (InvalidOperationException) - { - // Path resolution failed or operation error - return Attempt.FailWithStatus( - ContentPatchingOperationStatus.InvalidOperation, - default(UpdateDocumentRequestModel)!); - } - catch (FormatException) - { - // Path syntax error - return Attempt.FailWithStatus( - ContentPatchingOperationStatus.InvalidOperation, - default(UpdateDocumentRequestModel)!); - } - } - - currentJsonString = currentJsonNode.ToJsonString(); - - // Deserialize the modified JSON back to UpdateDocumentRequestModel - UpdateDocumentRequestModel? modifiedUpdateModel = _jsonSerializer.Deserialize(currentJsonString); - - return modifiedUpdateModel is not null - ? Attempt.SucceedWithStatus(ContentPatchingOperationStatus.Success, modifiedUpdateModel) - : Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); - } -} +using System.Text.Json; +using System.Text.Json.Nodes; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.OperationStatus; +using Umbraco.Cms.Api.Management.Patching; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Api.Management.ViewModels.Patching; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Api.Management.Patchers; + +/// +/// Applies patch operations with Umbraco's custom path syntax to document update models. +/// +public class DocumentPatcher : IDocumentPatcher +{ + private readonly IContentEditingService _contentEditingService; + private readonly IJsonSerializer _jsonSerializer; + private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; + + public DocumentPatcher( + IContentEditingService contentEditingService, + IJsonSerializer jsonSerializer, + IDocumentEditingPresentationFactory documentEditingPresentationFactory) + { + _contentEditingService = contentEditingService; + _jsonSerializer = jsonSerializer; + _documentEditingPresentationFactory = documentEditingPresentationFactory; + } + + /// + public async Task> ApplyPatchAsync( + Guid documentKey, + ContentPatchModel patchModel) + { + // Validate operation structure + foreach (PatchOperationModel operation in patchModel.Operations) + { + if (!PatchPathParser.IsValid(operation.Path, out PatchPathSegment[]? pathSegments)) + { + return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); + } + + operation.PathSegments = pathSegments; + + // Validate that replace/add operations have a value + if (operation.Op is PatchOperationType.Replace or PatchOperationType.Add && + operation.Value is null) + { + return Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); + } + } + + // Load the content + IContent? content = await _contentEditingService.GetAsync(documentKey); + if (content is null) + { + return Attempt.FailWithStatus(ContentPatchingOperationStatus.NotFound, default(UpdateDocumentRequestModel)!); + } + + // Convert to JSON as if a client would have sent the full payload + UpdateDocumentRequestModel unModifiedUpdateModel = await _documentEditingPresentationFactory.CreateUpdateRequestModelAsync(content); + var currentJsonString = _jsonSerializer.Serialize(unModifiedUpdateModel); + + if (string.IsNullOrWhiteSpace(currentJsonString)) + { + // should not happen as the content exists. + throw new JsonException("Unexpected empty JSON string when building update model for patching."); + } + + // Should not fail parsing as the string is a result of JSON serialization. + JsonNode currentJsonNode = JsonNode.Parse(currentJsonString) + ?? throw new JsonException("Could not parse JSON string to JsonNode when building update model for patching."); + + // Apply each PATCH operation to the JSON + foreach (PatchOperationModel operation in patchModel.Operations) + { + try + { + currentJsonNode = PatchEngine.ApplyOperation( + currentJsonNode, + operation.Op, + operation.PathSegments!, // can't be null because of earlier validation. + operation.Value); + } + catch (InvalidOperationException) + { + // Path resolution failed or operation error + return Attempt.FailWithStatus( + ContentPatchingOperationStatus.InvalidOperation, + default(UpdateDocumentRequestModel)!); + } + catch (FormatException) + { + // Path syntax error + return Attempt.FailWithStatus( + ContentPatchingOperationStatus.InvalidOperation, + default(UpdateDocumentRequestModel)!); + } + } + + currentJsonString = currentJsonNode.ToJsonString(); + + // Deserialize the modified JSON back to UpdateDocumentRequestModel + UpdateDocumentRequestModel? modifiedUpdateModel = _jsonSerializer.Deserialize(currentJsonString); + + return modifiedUpdateModel is not null + ? Attempt.SucceedWithStatus(ContentPatchingOperationStatus.Success, modifiedUpdateModel) + : Attempt.FailWithStatus(ContentPatchingOperationStatus.InvalidOperation, default(UpdateDocumentRequestModel)!); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs index 1c8aa52631a4..ce229e53976f 100644 --- a/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchEngine.cs @@ -24,6 +24,22 @@ public static JsonNode ApplyOperation(JsonNode jsonNode, PatchOperationType op, { PatchPathSegment[] segments = PatchPathParser.Parse(path); + return ApplyOperation(jsonNode, op, segments, value); + } + + /// + /// Applies a single patch operation to a JSON node and returns the modified JSON node. + /// + /// The JSON node to modify. + /// The operation type (Replace, Add, Remove). + /// The patch path segments. + /// The value to set (required for Replace and Add operations). + /// The modified JSON node. + /// Thrown when is null or whitespace. + /// Thrown when the operation cannot be applied. + /// Thrown when the path syntax is invalid. + public static JsonNode ApplyOperation(JsonNode jsonNode, PatchOperationType op, PatchPathSegment[] pathSegments, object? value) + { if (jsonNode is null) { throw new InvalidOperationException("Failed to parse JSON string."); @@ -33,7 +49,7 @@ public static JsonNode ApplyOperation(JsonNode jsonNode, PatchOperationType op, ? JsonSerializer.SerializeToNode(value) : null; - ResolvedTarget target = PatchPathResolver.Resolve(jsonNode, segments); + ResolvedTarget target = PatchPathResolver.Resolve(jsonNode, pathSegments); ApplyMutation(target, op, valueNode); return jsonNode; diff --git a/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs b/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs index 75cbdad396f4..eedbce59288b 100644 --- a/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs +++ b/src/Umbraco.Cms.Api.Management/Patching/PatchPathParser.cs @@ -111,8 +111,8 @@ public static PatchPathSegment[] Parse(string path) /// /// Validates that a path string is syntactically correct. /// - /// The path expression to validate. - /// The parsed patch path segments returned in an out parameter. + /// The path expression to validate. + /// The parsedPath extracted from the path if the path is valid. /// True if the path is valid, false otherwise. public static bool IsValid(string path, [NotNullWhen(true)]out PatchPathSegment[]? parsedPath) { @@ -133,94 +133,6 @@ public static bool IsValid(string path, [NotNullWhen(true)]out PatchPathSegment[ } } - /// - /// Extracts all culture values from filter segments in a path. - /// - public static ISet ExtractCultures(string path) - { - var cultures = new HashSet(StringComparer.OrdinalIgnoreCase); - - if (!IsValid(path, out PatchPathSegment[]? segments)) - { - return cultures; - } - - foreach (PatchPathSegment segment in segments) - { - if (segment is FilterSegment filter) - { - foreach (FilterCondition condition in filter.Conditions) - { - if (string.Equals(condition.Key, "culture", StringComparison.OrdinalIgnoreCase) - && condition.Value is not null) - { - cultures.Add(condition.Value); - } - } - } - } - - return cultures; - } - - /// - /// Extracts all segment values from filter segments in a path. - /// - public static ISet ExtractSegments(string path) - { - var result = new HashSet(StringComparer.OrdinalIgnoreCase); - - if (!IsValid(path, out PatchPathSegment[]? segments)) - { - return result; - } - - foreach (PatchPathSegment segment in segments) - { - if (segment is FilterSegment filter) - { - foreach (FilterCondition condition in filter.Conditions) - { - if (string.Equals(condition.Key, "segment", StringComparison.OrdinalIgnoreCase) - && condition.Value is not null) - { - result.Add(condition.Value); - } - } - } - } - - return result; - } - - /// - /// Checks if a path targets invariant content (culture=null filter). - /// - public static bool TargetsInvariantCulture(string path) - { - if (!IsValid(path, out PatchPathSegment[]? segments)) - { - return false; - } - - foreach (PatchPathSegment segment in segments) - { - if (segment is FilterSegment filter) - { - foreach (FilterCondition condition in filter.Conditions) - { - if (string.Equals(condition.Key, "culture", StringComparison.OrdinalIgnoreCase) - && condition.Value is null) - { - return true; - } - } - } - } - - return false; - } - private static PatchPathSegment ParseToken(ReadOnlySpan token) { if (token.Length == 1 && token[0] == '-') diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationModel.cs index c10377c85aef..6c59e5b9e605 100644 --- a/src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationModel.cs +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Patching/PatchOperationModel.cs @@ -1,3 +1,5 @@ +using Umbraco.Cms.Api.Management.Patching; + namespace Umbraco.Cms.Api.Management.ViewModels.Patching; /// @@ -15,6 +17,11 @@ public class PatchOperationModel /// public string Path { get; set; } = string.Empty; + /// + /// Gets the PathSegments extracted from the Path expression. + /// + public PatchPathSegment[]? PathSegments { get; internal set; } + /// /// Gets or sets the value to set. Required for Replace and Add operations, null for Remove. /// diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathParserTests.cs index dbfec555966b..7915e7d61cba 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathParserTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Management/Patching/PatchPathParserTests.cs @@ -164,88 +164,6 @@ public void IsValid_InvalidSyntax_ReturnsFalse() Assert.That(PatchPathParser.IsValid("/values[alias=title", out _), Is.False); } - [Test] - public void ExtractCultures_PathWithCulture_ReturnsCulture() - { - var cultures = PatchPathParser.ExtractCultures("/values[alias=title,culture=en-US,segment=null]/value"); - - Assert.That(cultures, Has.Count.EqualTo(1)); - Assert.That(cultures, Does.Contain("en-US")); - } - - [Test] - public void ExtractCultures_PathWithNullCulture_ReturnsEmpty() - { - var cultures = PatchPathParser.ExtractCultures("/values[alias=title,culture=null,segment=null]/value"); - - Assert.That(cultures, Is.Empty); - } - - [Test] - public void ExtractCultures_PathWithoutCulture_ReturnsEmpty() - { - var cultures = PatchPathParser.ExtractCultures("/values[alias=title]/value"); - - Assert.That(cultures, Is.Empty); - } - - [Test] - public void ExtractCultures_InvalidPath_ReturnsEmpty() - { - var cultures = PatchPathParser.ExtractCultures(string.Empty); - - Assert.That(cultures, Is.Empty); - } - - [Test] - public void ExtractSegments_PathWithSegment_ReturnsSegment() - { - var segments = PatchPathParser.ExtractSegments("/values[alias=price,culture=en-US,segment=premium]/value"); - - Assert.That(segments, Has.Count.EqualTo(1)); - Assert.That(segments, Does.Contain("premium")); - } - - [Test] - public void ExtractSegments_PathWithNullSegment_ReturnsEmpty() - { - var segments = PatchPathParser.ExtractSegments("/values[alias=price,segment=null]/value"); - - Assert.That(segments, Is.Empty); - } - - [Test] - public void ExtractSegments_PathWithoutSegment_ReturnsEmpty() - { - var segments = PatchPathParser.ExtractSegments("/values[alias=title]/value"); - - Assert.That(segments, Is.Empty); - } - - [Test] - public void TargetsInvariantCulture_NullCulture_ReturnsTrue() - { - Assert.That(PatchPathParser.TargetsInvariantCulture("/values[alias=title,culture=null]/value"), Is.True); - } - - [Test] - public void TargetsInvariantCulture_SpecificCulture_ReturnsFalse() - { - Assert.That(PatchPathParser.TargetsInvariantCulture("/values[alias=title,culture=en-US]/value"), Is.False); - } - - [Test] - public void TargetsInvariantCulture_NoCulture_ReturnsFalse() - { - Assert.That(PatchPathParser.TargetsInvariantCulture("/values[alias=title]/value"), Is.False); - } - - [Test] - public void TargetsInvariantCulture_InvalidPath_ReturnsFalse() - { - Assert.That(PatchPathParser.TargetsInvariantCulture(string.Empty), Is.False); - } - // RFC 6901 escape sequence tests [Test]