From 548b5e41506fa0d03011153ec03df1edd77a140f Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Wed, 25 Sep 2024 15:34:14 +0200 Subject: [PATCH] Enable validation of specific cultures only for document updates (#17087) * Enable validation of specific cultures only for document updates * Only validate explicitly sent cultures in the create validation endpoint * Fix backwards compat (obsolete old method) --------- Co-authored-by: Mads Rasmussen --- .../ValidateUpdateDocumentController.cs | 28 ++- .../DocumentEditingPresentationFactory.cs | 14 +- .../IDocumentEditingPresentationFactory.cs | 2 + src/Umbraco.Cms.Api.Management/OpenApi.json | 189 ++++++++++++++++++ .../ValidateUpdateDocumentRequestModel.cs | 6 + .../ValidateContentUpdateModel.cs | 6 + .../Services/ContentEditingService.cs | 16 +- .../Services/ContentEditingServiceBase.cs | 10 +- .../Services/ContentValidationService.cs | 5 +- .../Services/ContentValidationServiceBase.cs | 5 +- .../Services/IContentEditingService.cs | 3 + .../Services/IContentValidationServiceBase.cs | 2 +- .../Services/MediaValidationService.cs | 5 +- .../Services/MemberValidationService.cs | 5 +- .../Services/ContentValidationServiceTests.cs | 105 ++++++++++ 15 files changed, 383 insertions(+), 18 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Management/ViewModels/Document/ValidateUpdateDocumentRequestModel.cs create mode 100644 src/Umbraco.Core/Models/ContentEditing/ValidateContentUpdateModel.cs diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs index f80203d1351d..05f029f58208 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs @@ -12,6 +12,7 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document; [ApiVersion("1.0")] +[ApiVersion("1.1")] public class ValidateUpdateDocumentController : UpdateDocumentControllerBase { private readonly IContentEditingService _contentEditingService; @@ -32,10 +33,35 @@ public ValidateUpdateDocumentController( [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [Obsolete("Please use version 1.1 of this API. Will be removed in V16.")] public async Task Validate(CancellationToken cancellationToken, Guid id, UpdateDocumentRequestModel requestModel) => await HandleRequest(id, requestModel, async () => { - ContentUpdateModel model = _documentEditingPresentationFactory.MapUpdateModel(requestModel); + var validateUpdateDocumentRequestModel = new ValidateUpdateDocumentRequestModel + { + Values = requestModel.Values, + Variants = requestModel.Variants, + Template = requestModel.Template, + Cultures = null + }; + + ValidateContentUpdateModel model = _documentEditingPresentationFactory.MapValidateUpdateModel(validateUpdateDocumentRequestModel); + Attempt result = await _contentEditingService.ValidateUpdateAsync(id, model); + + return result.Success + ? Ok() + : DocumentEditingOperationStatusResult(result.Status, requestModel, result.Result); + }); + + [HttpPut("{id:guid}/validate")] + [MapToApiVersion("1.1")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task ValidateV1_1(CancellationToken cancellationToken, Guid id, ValidateUpdateDocumentRequestModel requestModel) + => await HandleRequest(id, requestModel, async () => + { + ValidateContentUpdateModel model = _documentEditingPresentationFactory.MapValidateUpdateModel(requestModel); Attempt result = await _contentEditingService.ValidateUpdateAsync(id, model); return result.Success diff --git a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs index 4f96cdb09902..31b1a9a66f50 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/DocumentEditingPresentationFactory.cs @@ -17,8 +17,20 @@ public ContentCreateModel MapCreateModel(CreateDocumentRequestModel requestModel } public ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel) + => MapUpdateContentModel(requestModel); + + public ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel) + { + ValidateContentUpdateModel model = MapUpdateContentModel(requestModel); + model.Cultures = requestModel.Cultures; + + return model; + } + + private TUpdateModel MapUpdateContentModel(UpdateDocumentRequestModel requestModel) + where TUpdateModel : ContentUpdateModel, new() { - ContentUpdateModel model = MapContentEditingModel(requestModel); + TUpdateModel model = MapContentEditingModel(requestModel); model.TemplateKey = requestModel.Template?.Id; return model; diff --git a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs index dbc85385637a..52978698d4b7 100644 --- a/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs +++ b/src/Umbraco.Cms.Api.Management/Factories/IDocumentEditingPresentationFactory.cs @@ -8,4 +8,6 @@ public interface IDocumentEditingPresentationFactory ContentCreateModel MapCreateModel(CreateDocumentRequestModel requestModel); ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel); + + ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel); } diff --git a/src/Umbraco.Cms.Api.Management/OpenApi.json b/src/Umbraco.Cms.Api.Management/OpenApi.json index af7ce7081a44..29acbdd16a76 100644 --- a/src/Umbraco.Cms.Api.Management/OpenApi.json +++ b/src/Umbraco.Cms.Api.Management/OpenApi.json @@ -9372,6 +9372,149 @@ } } }, + "deprecated": true, + "security": [ + { + "Backoffice User": [ ] + } + ] + } + }, + "/umbraco/management/api/v1.1/document/{id}/validate": { + "put": { + "tags": [ + "Document" + ], + "operationId": "PutUmbracoManagementApiV1.1DocumentByIdValidate1.1", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ValidateUpdateDocumentRequestModel" + } + ] + } + }, + "text/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ValidateUpdateDocumentRequestModel" + } + ] + } + }, + "application/*+json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ValidateUpdateDocumentRequestModel" + } + ] + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + }, + "400": { + "description": "Bad Request", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + }, + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + } + ] + } + } + } + }, + "401": { + "description": "The resource is protected and requires an authentication token" + }, + "403": { + "description": "The authenticated user do not have access to this resource", + "headers": { + "Umb-Notifications": { + "description": "The list of notifications produced during the request.", + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationHeaderModel" + }, + "nullable": true + } + } + } + } + }, "security": [ { "Backoffice User": [ ] @@ -45526,6 +45669,52 @@ }, "additionalProperties": false }, + "ValidateUpdateDocumentRequestModel": { + "required": [ + "values", + "variants" + ], + "type": "object", + "properties": { + "values": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentValueModel" + } + ] + } + }, + "variants": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/components/schemas/DocumentVariantRequestModel" + } + ] + } + }, + "template": { + "oneOf": [ + { + "$ref": "#/components/schemas/ReferenceByIdModel" + } + ], + "nullable": true + }, + "cultures": { + "uniqueItems": true, + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + }, + "additionalProperties": false + }, "VariantItemResponseModel": { "required": [ "name" diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/ValidateUpdateDocumentRequestModel.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/ValidateUpdateDocumentRequestModel.cs new file mode 100644 index 000000000000..806c733cc217 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/ValidateUpdateDocumentRequestModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +public class ValidateUpdateDocumentRequestModel : UpdateDocumentRequestModel +{ + public ISet? Cultures { get; set; } +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ValidateContentUpdateModel.cs b/src/Umbraco.Core/Models/ContentEditing/ValidateContentUpdateModel.cs new file mode 100644 index 000000000000..159398c3b1ef --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ValidateContentUpdateModel.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class ValidateContentUpdateModel : ContentUpdateModel +{ + public ISet? Cultures { get; set; } +} diff --git a/src/Umbraco.Core/Services/ContentEditingService.cs b/src/Umbraco.Core/Services/ContentEditingService.cs index 2ad8365bcfcf..a068f61b7a9b 100644 --- a/src/Umbraco.Core/Services/ContentEditingService.cs +++ b/src/Umbraco.Core/Services/ContentEditingService.cs @@ -36,6 +36,7 @@ public ContentEditingService( return await Task.FromResult(content); } + [Obsolete("Please use the validate update method that is not obsoleted. Will be removed in V16.")] public async Task> ValidateUpdateAsync(Guid key, ContentUpdateModel updateModel) { IContent? content = ContentService.GetById(key); @@ -44,8 +45,16 @@ public async Task> ValidateUpdateAsync(Guid key, ValidateContentUpdateModel updateModel) + { + IContent? content = ContentService.GetById(key); + return content is not null + ? await ValidateCulturesAndPropertiesAsync(updateModel, content.ContentType.Key, updateModel.Cultures) + : Attempt.FailWithStatus(ContentEditingOperationStatus.NotFound, new ContentValidationResult()); + } + public async Task> ValidateCreateAsync(ContentCreateModel createModel) - => await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey); + => await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, createModel.Variants.Select(variant => variant.Culture)); public async Task> CreateAsync(ContentCreateModel createModel, Guid userKey) { @@ -137,10 +146,11 @@ public async Task SortAsync(Guid? parentKey, IEnu private async Task> ValidateCulturesAndPropertiesAsync( ContentEditingModelBase contentEditingModelBase, - Guid contentTypeKey) + Guid contentTypeKey, + IEnumerable? culturesToValidate = null) => await ValidateCulturesAsync(contentEditingModelBase) is false ? Attempt.FailWithStatus(ContentEditingOperationStatus.InvalidCulture, new ContentValidationResult()) - : await ValidatePropertiesAsync(contentEditingModelBase, contentTypeKey); + : await ValidatePropertiesAsync(contentEditingModelBase, contentTypeKey, culturesToValidate); private async Task UpdateTemplateAsync(IContent content, Guid? templateKey) { diff --git a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs index 290fc2e5d50b..1f5c1dda9f89 100644 --- a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs +++ b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs @@ -113,7 +113,8 @@ protected async Task ValidateCulturesAsync(ContentEditingModelBase content protected async Task> ValidatePropertiesAsync( ContentEditingModelBase contentEditingModelBase, - Guid contentTypeKey) + Guid contentTypeKey, + IEnumerable? culturesToValidate = null) { TContentType? contentType = await ContentTypeService.GetAsync(contentTypeKey); if (contentType is null) @@ -121,14 +122,15 @@ protected async Task> ValidatePropertiesAsync( ContentEditingModelBase contentEditingModelBase, - TContentType contentType) + TContentType contentType, + IEnumerable? culturesToValidate = null) { - ContentValidationResult result = await _validationService.ValidatePropertiesAsync(contentEditingModelBase, contentType); + ContentValidationResult result = await _validationService.ValidatePropertiesAsync(contentEditingModelBase, contentType, culturesToValidate); return result.ValidationErrors.Any() is false ? Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, result) : Attempt.FailWithStatus(ContentEditingOperationStatus.PropertyValidationError, result); diff --git a/src/Umbraco.Core/Services/ContentValidationService.cs b/src/Umbraco.Core/Services/ContentValidationService.cs index 093c9eaff39a..0f93278c5324 100644 --- a/src/Umbraco.Core/Services/ContentValidationService.cs +++ b/src/Umbraco.Core/Services/ContentValidationService.cs @@ -12,6 +12,7 @@ public ContentValidationService(IPropertyValidationService propertyValidationSer public async Task ValidatePropertiesAsync( ContentEditingModelBase contentEditingModelBase, - IContentType contentType) - => await HandlePropertiesValidationAsync(contentEditingModelBase, contentType); + IContentType contentType, + IEnumerable? culturesToValidate = null) + => await HandlePropertiesValidationAsync(contentEditingModelBase, contentType, culturesToValidate); } diff --git a/src/Umbraco.Core/Services/ContentValidationServiceBase.cs b/src/Umbraco.Core/Services/ContentValidationServiceBase.cs index b4cc9f2e515a..63add2473165 100644 --- a/src/Umbraco.Core/Services/ContentValidationServiceBase.cs +++ b/src/Umbraco.Core/Services/ContentValidationServiceBase.cs @@ -23,7 +23,8 @@ protected ContentValidationServiceBase( protected async Task HandlePropertiesValidationAsync( ContentEditingModelBase contentEditingModelBase, - TContentType contentType) + TContentType contentType, + IEnumerable? culturesToValidate = null) { var validationErrors = new List(); @@ -43,7 +44,7 @@ protected async Task HandlePropertiesValidationAsync( return new ContentValidationResult { ValidationErrors = validationErrors }; } - var cultures = await GetCultureCodes(); + var cultures = culturesToValidate?.ToArray() ?? await GetCultureCodes(); // we don't have any managed segments, so we have to make do with the ones passed in the model var segments = contentEditingModelBase.Variants.DistinctBy(variant => variant.Segment).Select(variant => variant.Segment).ToArray(); diff --git a/src/Umbraco.Core/Services/IContentEditingService.cs b/src/Umbraco.Core/Services/IContentEditingService.cs index dc2fa928903b..260e5ae93494 100644 --- a/src/Umbraco.Core/Services/IContentEditingService.cs +++ b/src/Umbraco.Core/Services/IContentEditingService.cs @@ -10,8 +10,11 @@ public interface IContentEditingService Task> ValidateCreateAsync(ContentCreateModel createModel); + [Obsolete("Please use the validate update method that is not obsoleted. Will be removed in V16.")] Task> ValidateUpdateAsync(Guid key, ContentUpdateModel updateModel); + Task> ValidateUpdateAsync(Guid key, ValidateContentUpdateModel updateModel); + Task> CreateAsync(ContentCreateModel createModel, Guid userKey); Task> UpdateAsync(Guid key, ContentUpdateModel updateModel, Guid userKey); diff --git a/src/Umbraco.Core/Services/IContentValidationServiceBase.cs b/src/Umbraco.Core/Services/IContentValidationServiceBase.cs index 4218881766e5..7c91bdd0ec8a 100644 --- a/src/Umbraco.Core/Services/IContentValidationServiceBase.cs +++ b/src/Umbraco.Core/Services/IContentValidationServiceBase.cs @@ -6,7 +6,7 @@ namespace Umbraco.Cms.Core.Services; internal interface IContentValidationServiceBase where TContentType : IContentTypeComposition { - Task ValidatePropertiesAsync(ContentEditingModelBase contentEditingModelBase, TContentType contentType); + Task ValidatePropertiesAsync(ContentEditingModelBase contentEditingModelBase, TContentType contentType, IEnumerable? culturesToValidate = null); Task ValidateCulturesAsync(ContentEditingModelBase contentEditingModelBase); } diff --git a/src/Umbraco.Core/Services/MediaValidationService.cs b/src/Umbraco.Core/Services/MediaValidationService.cs index 872dce7b2826..0b46e61a877e 100644 --- a/src/Umbraco.Core/Services/MediaValidationService.cs +++ b/src/Umbraco.Core/Services/MediaValidationService.cs @@ -12,6 +12,7 @@ public MediaValidationService(IPropertyValidationService propertyValidationServi public async Task ValidatePropertiesAsync( ContentEditingModelBase contentEditingModelBase, - IMediaType mediaType) - => await HandlePropertiesValidationAsync(contentEditingModelBase, mediaType); + IMediaType mediaType, + IEnumerable? culturesToValidate = null) + => await HandlePropertiesValidationAsync(contentEditingModelBase, mediaType, culturesToValidate); } diff --git a/src/Umbraco.Core/Services/MemberValidationService.cs b/src/Umbraco.Core/Services/MemberValidationService.cs index 79ec4d75fc3f..db9e97587adb 100644 --- a/src/Umbraco.Core/Services/MemberValidationService.cs +++ b/src/Umbraco.Core/Services/MemberValidationService.cs @@ -12,6 +12,7 @@ public MemberValidationService(IPropertyValidationService propertyValidationServ public async Task ValidatePropertiesAsync( ContentEditingModelBase contentEditingModelBase, - IMemberType memberType) - => await HandlePropertiesValidationAsync(contentEditingModelBase, memberType); + IMemberType memberType, + IEnumerable? culturesToValidate = null) + => await HandlePropertiesValidationAsync(contentEditingModelBase, memberType, culturesToValidate); } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentValidationServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentValidationServiceTests.cs index 5ba83bafacf6..ef8d46a07196 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentValidationServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentValidationServiceTests.cs @@ -317,6 +317,93 @@ public async Task Can_Validate_Culture_Code(string cultureCode, bool expectedRes Assert.AreEqual(expectedResult, result); } + [Test] + public async Task Can_Validate_For_All_Languages() + { + var contentType = await SetupLanguageTest(); + + var validationResult = await ContentValidationService.ValidatePropertiesAsync( + new ContentCreateModel + { + ContentTypeKey = contentType.Key, + Variants = [ + new() + { + Name = "Test Document (EN)", + Culture = "en-US", + Properties = [ + new() + { + Alias = "title", + Value = "Invalid value in English", + } + ] + }, + new() + { + Name = "Test Document (DA)", + Culture = "da-DK", + Properties = [ + new() + { + Alias = "title", + Value = "Invalid value in Danish", + } + ] + } + ] + }, + contentType); + + Assert.AreEqual(2, validationResult.ValidationErrors.Count()); + Assert.IsNotNull(validationResult.ValidationErrors.SingleOrDefault(r => r.Alias == "title" && r.Culture == "en-US" && r.JsonPath == string.Empty)); + Assert.IsNotNull(validationResult.ValidationErrors.SingleOrDefault(r => r.Alias == "title" && r.Culture == "da-DK" && r.JsonPath == string.Empty)); + } + + [TestCase("da-DK")] + [TestCase("en-US")] + public async Task Can_Validate_For_Specific_Language(string culture) + { + var contentType = await SetupLanguageTest(); + + var validationResult = await ContentValidationService.ValidatePropertiesAsync( + new ContentCreateModel + { + ContentTypeKey = contentType.Key, + Variants = [ + new() + { + Name = "Test Document (EN)", + Culture = "en-US", + Properties = [ + new() + { + Alias = "title", + Value = "Invalid value in English", + } + ] + }, + new() + { + Name = "Test Document (DA)", + Culture = "da-DK", + Properties = [ + new() + { + Alias = "title", + Value = "Invalid value in Danish", + } + ] + } + ] + }, + contentType, + [culture]); + + Assert.AreEqual(1, validationResult.ValidationErrors.Count()); + Assert.IsNotNull(validationResult.ValidationErrors.SingleOrDefault(r => r.Alias == "title" && r.Culture == culture && r.JsonPath == string.Empty)); + } + private async Task<(IContentType DocumentType, IContentType ElementType)> SetupBlockListTest() { var propertyEditorCollection = GetRequiredService(); @@ -398,4 +485,22 @@ private IContentType SetupSimpleTest() return contentType; } + + private async Task SetupLanguageTest() + { + var language = new LanguageBuilder() + .WithCultureInfo("da-DK") + .Build(); + await LanguageService.CreateAsync(language, Constants.Security.SuperUserKey); + + var contentType = ContentTypeBuilder.CreateSimpleContentType("umbMandatory", "Mandatory Doc Type"); + contentType.Variations = ContentVariation.Culture; + var titlePropertyType = contentType.PropertyTypes.First(pt => pt.Alias == "title"); + titlePropertyType.Variations = ContentVariation.Culture; + titlePropertyType.ValidationRegExp = "^Valid.*$"; + contentType.AllowedAsRoot = true; + await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey); + + return contentType; + } }