Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
1496282
Document patch, variant name only
Migaroez Jan 26, 2026
90f55f3
Multi variant tests
Migaroez Jan 26, 2026
6e246f1
Change to json-patch instead of merge to target nested properties
Migaroez Jan 27, 2026
ab7514e
Fix ManagementApiTest following PR 20820
Migaroez Jan 27, 2026
cfc1bd9
Segment suport for properties
Migaroez Jan 27, 2026
6a31bc2
Verify non existing and trashed document patch behaviour
Migaroez Jan 27, 2026
f27f536
Mostly working approuch for nested properties
Migaroez Feb 1, 2026
1bff7a3
Fix endpoint route collision (Somehow...)
Migaroez Feb 1, 2026
9d2b26f
Trying a custom way of doing things
Migaroez Mar 2, 2026
07a1c01
Merge branch 'main' into v17/task/document-patch
Migaroez Mar 2, 2026
24eff60
add escape support, more tests and cleanup
Migaroez Mar 2, 2026
f615980
remove unnecesary using
Migaroez Mar 2, 2026
1a03c12
Cleanup
Migaroez Mar 4, 2026
83a60bd
Restore things that are breaking
Migaroez Mar 11, 2026
746cece
cleanup
Migaroez Mar 11, 2026
4501d21
Namespace cleanup
Migaroez Mar 11, 2026
9e9fde6
Order cleanup
Migaroez Mar 11, 2026
cbec759
More comment updates
Migaroez Mar 11, 2026
c2f1eee
Merge branch 'main' into v17/task/document-patch
AndyButland Mar 19, 2026
6e00a16
Add default implementations
Migaroez Mar 24, 2026
7a9847b
Improve modelbinding validation
Migaroez Mar 24, 2026
19ba6de
all string comparison
Migaroez Mar 24, 2026
632954a
Cleanup unused statuses
Migaroez Mar 24, 2026
59175dd
Fix PatchPathResolver Filtering not accepting non string values
Migaroez Mar 24, 2026
54a2edb
Optimize path parsing
Migaroez Mar 24, 2026
2127de8
Improve cookie token rework
Migaroez Mar 24, 2026
5eedb37
more cleanup
Migaroez Mar 24, 2026
03a743c
Put AllowedValues on the correct property 🙈
Migaroez Mar 24, 2026
b7321f9
Merge branch 'main' into v17/task/document-patch
AndyButland Mar 25, 2026
8806113
One more default implementation
Migaroez Mar 25, 2026
da1cacc
Add link to docs on endpoint swagger info
Migaroez Mar 25, 2026
c398646
PR review corrections
Migaroez Apr 3, 2026
c2b01b8
Update documentation urls
Migaroez Apr 3, 2026
1cacb39
Apply suggestions from code review
Migaroez Apr 5, 2026
39c266c
Removed affected variance tracking that is nog longer being used
Migaroez Apr 5, 2026
ba21767
Extract shared data class
Migaroez Apr 5, 2026
2025951
update claude patching namespace
Migaroez Apr 5, 2026
33f1ef1
Remove no longer valid xml comment
Migaroez Apr 5, 2026
7e1111f
Fix unittests after refactoring patchengine.ApplyOperation(string,...…
Migaroez Apr 5, 2026
e8c55ad
Refactor base classes
Migaroez Apr 7, 2026
93a439b
Apply suggestions from code review
Migaroez Apr 8, 2026
cf5043c
Optimizations and refactoring of the patcher/engine/parser
Migaroez Apr 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/Umbraco.Cms.Api.Management/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ 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.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`

Expand Down Expand Up @@ -70,7 +71,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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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.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;
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 IDocumentPatcher _documentPatcher;
private readonly IDocumentEditingPresentationFactory _presentationFactory;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;

public PatchDocumentController(
IAuthorizationService authorizationService,
IContentEditingService contentEditingService,
IDocumentPatcher documentPatcher,
IDocumentEditingPresentationFactory presentationFactory,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
: base(authorizationService)
{
_contentEditingService = contentEditingService;
_documentPatcher = documentPatcher;
_presentationFactory = presentationFactory;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}

[HttpPatch("{id:guid}/patch")]
Comment thread
Migaroez marked this conversation as resolved.
[MapToApiVersion("1.0")]
[ProducesResponseType(StatusCodes.Status200OK)]
[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-endpoint-guide or https://docs.umbraco.com/umbraco-cms/reference/management-api/patching/document-endpoint-spec")]
Comment thread
AndyButland marked this conversation as resolved.
[Consumes("application/json-patch+json")]
public async Task<IActionResult> Patch(
CancellationToken cancellationToken,
Guid id,
PatchDocumentRequestModel requestModel)
=> await HandleRequest(id, async () =>
{
ContentPatchModel patchModel = _presentationFactory.MapPatchModel(requestModel);

// Apply PATCH operations to create an update request model
Attempt<UpdateDocumentRequestModel, ContentPatchingOperationStatus> patchResult =
await _documentPatcher.ApplyPatchAsync(id, patchModel);

if (patchResult.Success is false)
{
return ContentPatchingOperationStatusResult(patchResult.Status);
}

ContentUpdateModel contentUpdateModel = _presentationFactory.MapUpdateModel(patchResult.Result);

// Use the standard update method to save the patched content
Attempt<ContentUpdateResult, ContentEditingOperationStatus> updateResult =
await _contentEditingService.UpdateAsync(id, contentUpdateModel, CurrentUserKey(_backOfficeSecurityAccessor));

return updateResult.Success
? Ok()
: ContentEditingOperationStatusResult(updateResult.Status);
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.OperationStatus;

namespace Umbraco.Cms.Api.Management.Controllers.Document;

public abstract class PatchDocumentControllerBase : UpdateDocumentControllerBase
{
protected PatchDocumentControllerBase(IAuthorizationService authorizationService)
: base(authorizationService)
{
}

/// <summary>
/// Maps ContentPatchingOperationStatus to appropriate HTTP responses for PATCH operations.
/// </summary>
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, path syntax, and operation types.")
.Build()),
ContentPatchingOperationStatus.NotFound => NotFound(problemDetailsBuilder
.WithTitle("The document could not be found")
.Build()),
_ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder
.WithTitle("Unknown error")
.WithDetail("An unexpected error occurred during the PATCH operation.")
.Build()),
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ protected UpdateDocumentControllerBase(IAuthorizationService authorizationServic
=> _authorizationService = authorizationService;

protected async Task<IActionResult> HandleRequest(Guid id, UpdateDocumentRequestModel requestModel, Func<Task<IActionResult>> authorizedHandler)
=> await HandleRequest(id, authorizedHandler);

protected async Task<IActionResult> HandleRequest(Guid id, Func<Task<IActionResult>> 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,6 +22,7 @@ internal static IUmbracoBuilder AddDocuments(this IUmbracoBuilder builder)
builder.Services.AddTransient<IDomainPresentationFactory, DomainPresentationFactory>();
builder.Services.AddTransient<IDocumentVersionPresentationFactory, DocumentVersionPresentationFactory>();
builder.Services.AddTransient<IDocumentCollectionPresentationFactory, DocumentCollectionPresentationFactory>();
builder.Services.AddTransient<IDocumentPatcher, DocumentPatcher>();

builder.WithCollectionBuilder<MapDefinitionCollectionBuilder>()
.Add<DocumentMapDefinition>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,22 @@
namespace Umbraco.Cms.Api.Management.DependencyInjection;

/// <summary>
/// Provides extension methods for configuring JSON serialization in the Umbraco CMS Management API.
/// Extension methods for registering JSON-related services.
/// </summary>
[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
{
/// <summary>
/// Adds JSON-related services to the Umbraco builder.
/// </summary>
/// <param name="builder">The Umbraco builder.</param>
/// <returns>The Umbraco builder.</returns>
internal static IUmbracoBuilder AddJson(this IUmbracoBuilder builder)
{
#pragma warning disable CS0618 // Type or member is obsolete
builder.Services
.AddTransient<IJsonPatchService, JsonPatchService>();
#pragma warning restore CS0618 // Type or member is obsolete

return builder;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@
builder.Services.AddUnique<IConflictingRouteService, ConflictingRouteService>();
builder.AddUmbracoApiOpenApiUI();

#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

Check warning on line 41 in src/Umbraco.Cms.Api.Management/DependencyInjection/UmbracoBuilderExtensions.cs

View check run for this annotation

CodeScene Delta Analysis / CodeScene Code Health Review (main)

❌ Getting worse: Large Method

AddUmbracoManagementApi increases from 78 to 82 lines of code, threshold = 70. Large functions with many lines of code are generally harder to understand and lower the code health. Avoid adding more lines to this function.
.AddInstaller()
.AddUpgrader()
.AddSearchManagement()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,41 @@
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.Services;
using Umbraco.Extensions;

namespace Umbraco.Cms.Api.Management.Factories;

/// <summary>
/// Factory for creating and mapping presentation models used in document editing operations.
/// </summary>
internal sealed class DocumentEditingPresentationFactory : ContentEditingPresentationFactory<DocumentValueModel, DocumentVariantRequestModel>, IDocumentEditingPresentationFactory
{
private readonly PropertyEditorCollection _propertyEditorCollection;
private readonly IDataValueEditorFactory _dataValueEditorFactory;
private readonly ITemplateService _templateService;

/// <summary>
/// Maps a <see cref="CreateDocumentRequestModel"/> to a <see cref="ContentCreateModel"/>.
/// Initializes a new instance of the <see cref="DocumentEditingPresentationFactory"/> class.
/// </summary>
/// <param name="requestModel">The request model containing data to create the content.</param>
/// <returns>A <see cref="ContentCreateModel"/> representing the content to be created.</returns>
/// <param name="propertyEditorCollection">The collection of available property editors.</param>
/// <param name="dataValueEditorFactory">The factory for creating data value editors.</param>
/// <param name="templateService">The service for retrieving templates.</param>
public DocumentEditingPresentationFactory(
PropertyEditorCollection propertyEditorCollection,
IDataValueEditorFactory dataValueEditorFactory,
ITemplateService templateService)
{
_propertyEditorCollection = propertyEditorCollection;
_dataValueEditorFactory = dataValueEditorFactory;
_templateService = templateService;
}

/// <inheritdoc/>
public ContentCreateModel MapCreateModel(CreateDocumentRequestModel requestModel)
{
ContentCreateModel model = MapContentEditingModel<ContentCreateModel>(requestModel);
Expand All @@ -21,19 +47,46 @@ public ContentCreateModel MapCreateModel(CreateDocumentRequestModel requestModel
return model;
}

/// <summary>
/// Maps the given <see cref="UpdateDocumentRequestModel"/> to a <see cref="ContentUpdateModel"/>.
/// </summary>
/// <param name="requestModel">The update document request model to map from.</param>
/// <returns>The mapped <see cref="ContentUpdateModel"/> instance.</returns>
/// <inheritdoc/>
public ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel)
=> MapUpdateContentModel<ContentUpdateModel>(requestModel);

/// <summary>
/// Maps a <see cref="ValidateUpdateDocumentRequestModel"/> to a <see cref="ValidateContentUpdateModel"/>, copying relevant validation data.
/// </summary>
/// <param name="requestModel">The request model containing the document update validation data.</param>
/// <returns>A <see cref="ValidateContentUpdateModel"/> populated with validation data from the request model.</returns>
/// <inheritdoc/>
public async Task<UpdateDocumentRequestModel> CreateUpdateRequestModelAsync(IContent content)
{
DocumentValueModel[] values = MapValuesToRequestModel(content.Properties);

DocumentVariantRequestModel[] variants = MapVariantsToRequestModel(content);

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,
};
}

/// <inheritdoc/>
public ContentPatchModel MapPatchModel(PatchDocumentRequestModel requestModel)
{
PatchOperationModel[] operations = requestModel.Operations.Select(op => new PatchOperationModel
{
Op = MapOperationType(op.Op),
Path = op.Path,
Value = op.Value,
}).ToArray();

return new ContentPatchModel
{
Operations = operations,
};
}

/// <inheritdoc/>
public ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel)
{
ValidateContentUpdateModel model = MapUpdateContentModel<ValidateContentUpdateModel>(requestModel);
Expand All @@ -42,6 +95,61 @@ public ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentR
return model;
}

private DocumentValueModel[] MapValuesToRequestModel(IPropertyCollection properties)
{
Dictionary<string, IDataEditor> 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();
}

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)),
Comment thread
Migaroez marked this conversation as resolved.
};

private TUpdateModel MapUpdateContentModel<TUpdateModel>(UpdateDocumentRequestModel requestModel)
where TUpdateModel : ContentUpdateModel, new()
{
Expand Down
Loading
Loading