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 <madsr@hey.com>
This commit is contained in:
Kenn Jacobsen
2024-09-25 15:34:14 +02:00
committed by GitHub
parent 474cffbf7c
commit 548b5e4150
15 changed files with 383 additions and 18 deletions

View File

@@ -12,6 +12,7 @@ using Umbraco.Cms.Core.Services.OperationStatus;
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 class ValidateUpdateDocumentController : UpdateDocumentControllerBase
[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<IActionResult> 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<ContentValidationResult, ContentEditingOperationStatus> 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<IActionResult> ValidateV1_1(CancellationToken cancellationToken, Guid id, ValidateUpdateDocumentRequestModel requestModel)
=> await HandleRequest(id, requestModel, async () =>
{
ValidateContentUpdateModel model = _documentEditingPresentationFactory.MapValidateUpdateModel(requestModel);
Attempt<ContentValidationResult, ContentEditingOperationStatus> result = await _contentEditingService.ValidateUpdateAsync(id, model);
return result.Success

View File

@@ -17,8 +17,20 @@ internal sealed class DocumentEditingPresentationFactory : ContentEditingPresent
}
public ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel)
=> MapUpdateContentModel<ContentUpdateModel>(requestModel);
public ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel)
{
ContentUpdateModel model = MapContentEditingModel<ContentUpdateModel>(requestModel);
ValidateContentUpdateModel model = MapUpdateContentModel<ValidateContentUpdateModel>(requestModel);
model.Cultures = requestModel.Cultures;
return model;
}
private TUpdateModel MapUpdateContentModel<TUpdateModel>(UpdateDocumentRequestModel requestModel)
where TUpdateModel : ContentUpdateModel, new()
{
TUpdateModel model = MapContentEditingModel<TUpdateModel>(requestModel);
model.TemplateKey = requestModel.Template?.Id;
return model;

View File

@@ -8,4 +8,6 @@ public interface IDocumentEditingPresentationFactory
ContentCreateModel MapCreateModel(CreateDocumentRequestModel requestModel);
ContentUpdateModel MapUpdateModel(UpdateDocumentRequestModel requestModel);
ValidateContentUpdateModel MapValidateUpdateModel(ValidateUpdateDocumentRequestModel requestModel);
}

View File

@@ -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"

View File

@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Document;
public class ValidateUpdateDocumentRequestModel : UpdateDocumentRequestModel
{
public ISet<string>? Cultures { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace Umbraco.Cms.Core.Models.ContentEditing;
public class ValidateContentUpdateModel : ContentUpdateModel
{
public ISet<string>? Cultures { get; set; }
}

View File

@@ -36,6 +36,7 @@ internal sealed class 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<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateUpdateAsync(Guid key, ContentUpdateModel updateModel)
{
IContent? content = ContentService.GetById(key);
@@ -44,8 +45,16 @@ internal sealed class ContentEditingService
: Attempt.FailWithStatus(ContentEditingOperationStatus.NotFound, new ContentValidationResult());
}
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> 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<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(ContentCreateModel createModel)
=> await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey);
=> await ValidateCulturesAndPropertiesAsync(createModel, createModel.ContentTypeKey, createModel.Variants.Select(variant => variant.Culture));
public async Task<Attempt<ContentCreateResult, ContentEditingOperationStatus>> CreateAsync(ContentCreateModel createModel, Guid userKey)
{
@@ -137,10 +146,11 @@ internal sealed class ContentEditingService
private async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCulturesAndPropertiesAsync(
ContentEditingModelBase contentEditingModelBase,
Guid contentTypeKey)
Guid contentTypeKey,
IEnumerable<string?>? 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<ContentEditingOperationStatus> UpdateTemplateAsync(IContent content, Guid? templateKey)
{

View File

@@ -113,7 +113,8 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
protected async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidatePropertiesAsync(
ContentEditingModelBase contentEditingModelBase,
Guid contentTypeKey)
Guid contentTypeKey,
IEnumerable<string?>? culturesToValidate = null)
{
TContentType? contentType = await ContentTypeService.GetAsync(contentTypeKey);
if (contentType is null)
@@ -121,14 +122,15 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
return Attempt.FailWithStatus(ContentEditingOperationStatus.ContentTypeNotFound, new ContentValidationResult());
}
return await ValidatePropertiesAsync(contentEditingModelBase, contentType);
return await ValidatePropertiesAsync(contentEditingModelBase, contentType, culturesToValidate);
}
private async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidatePropertiesAsync(
ContentEditingModelBase contentEditingModelBase,
TContentType contentType)
TContentType contentType,
IEnumerable<string?>? 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);

View File

@@ -12,6 +12,7 @@ internal sealed class ContentValidationService : ContentValidationServiceBase<IC
public async Task<ContentValidationResult> ValidatePropertiesAsync(
ContentEditingModelBase contentEditingModelBase,
IContentType contentType)
=> await HandlePropertiesValidationAsync(contentEditingModelBase, contentType);
IContentType contentType,
IEnumerable<string?>? culturesToValidate = null)
=> await HandlePropertiesValidationAsync(contentEditingModelBase, contentType, culturesToValidate);
}

View File

@@ -23,7 +23,8 @@ internal abstract class ContentValidationServiceBase<TContentType>
protected async Task<ContentValidationResult> HandlePropertiesValidationAsync(
ContentEditingModelBase contentEditingModelBase,
TContentType contentType)
TContentType contentType,
IEnumerable<string?>? culturesToValidate = null)
{
var validationErrors = new List<PropertyValidationError>();
@@ -43,7 +44,7 @@ internal abstract class ContentValidationServiceBase<TContentType>
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();

View File

@@ -10,8 +10,11 @@ public interface IContentEditingService
Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(ContentCreateModel createModel);
[Obsolete("Please use the validate update method that is not obsoleted. Will be removed in V16.")]
Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateUpdateAsync(Guid key, ContentUpdateModel updateModel);
Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateUpdateAsync(Guid key, ValidateContentUpdateModel updateModel);
Task<Attempt<ContentCreateResult, ContentEditingOperationStatus>> CreateAsync(ContentCreateModel createModel, Guid userKey);
Task<Attempt<ContentUpdateResult, ContentEditingOperationStatus>> UpdateAsync(Guid key, ContentUpdateModel updateModel, Guid userKey);

View File

@@ -6,7 +6,7 @@ namespace Umbraco.Cms.Core.Services;
internal interface IContentValidationServiceBase<in TContentType>
where TContentType : IContentTypeComposition
{
Task<ContentValidationResult> ValidatePropertiesAsync(ContentEditingModelBase contentEditingModelBase, TContentType contentType);
Task<ContentValidationResult> ValidatePropertiesAsync(ContentEditingModelBase contentEditingModelBase, TContentType contentType, IEnumerable<string?>? culturesToValidate = null);
Task<bool> ValidateCulturesAsync(ContentEditingModelBase contentEditingModelBase);
}

View File

@@ -12,6 +12,7 @@ internal sealed class MediaValidationService : ContentValidationServiceBase<IMed
public async Task<ContentValidationResult> ValidatePropertiesAsync(
ContentEditingModelBase contentEditingModelBase,
IMediaType mediaType)
=> await HandlePropertiesValidationAsync(contentEditingModelBase, mediaType);
IMediaType mediaType,
IEnumerable<string?>? culturesToValidate = null)
=> await HandlePropertiesValidationAsync(contentEditingModelBase, mediaType, culturesToValidate);
}

View File

@@ -12,6 +12,7 @@ internal sealed class MemberValidationService : ContentValidationServiceBase<IMe
public async Task<ContentValidationResult> ValidatePropertiesAsync(
ContentEditingModelBase contentEditingModelBase,
IMemberType memberType)
=> await HandlePropertiesValidationAsync(contentEditingModelBase, memberType);
IMemberType memberType,
IEnumerable<string?>? culturesToValidate = null)
=> await HandlePropertiesValidationAsync(contentEditingModelBase, memberType, culturesToValidate);
}

View File

@@ -317,6 +317,93 @@ public class ContentValidationServiceTests : UmbracoIntegrationTestWithContent
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<PropertyEditorCollection>();
@@ -398,4 +485,22 @@ public class ContentValidationServiceTests : UmbracoIntegrationTestWithContent
return contentType;
}
private async Task<IContentType> 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;
}
}