Property level validation for Management API (#15644)

* Property level validation for content - initial implementation

* Always succeed create/update regardless of property level validation errors

* Move old complex editor validation classes to Web.BackOffice so they will be deleted

* Include operation status and property validation errors in ProblemDetails

* Refactor property validation to its own service(s)

* Make the problem details builder a little more generic towards extensions

* Validation for item and branch publish

* Moved malplaced test

* Get rid of a TODO

* Integration tests for content validation service

* Simplify validation service

* Add missing response types to create and update for document and media

* Remove test that no longer applies

* Use "errors" for model validation errors (property validation errors)

* Split create/update and validation into their own endpoints

* Fix forward merge

* Correct wrong assumption for missing properties

* Remove localization from validation error messages - decreases dependencies, adds a lot of obsolete constructors

* Reuse existing validation service + support custom error messages

* Fix merge errors

* Review comments
This commit is contained in:
Kenn Jacobsen
2024-01-31 10:40:58 +01:00
committed by GitHub
parent e0e6dee896
commit aaf7075313
86 changed files with 2052 additions and 898 deletions

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Models.ContentEditing.Validation;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services.OperationStatus;
@@ -23,8 +24,9 @@ internal sealed class ContentEditingService
ILogger<ContentEditingService> logger,
ICoreScopeProvider scopeProvider,
IUserIdKeyResolver userIdKeyResolver,
ITreeEntitySortingService treeEntitySortingService)
: base(contentService, contentTypeService, propertyEditorCollection, dataTypeService, logger, scopeProvider, userIdKeyResolver, treeEntitySortingService)
ITreeEntitySortingService treeEntitySortingService,
IContentValidationService contentValidationService)
: base(contentService, contentTypeService, propertyEditorCollection, dataTypeService, logger, scopeProvider, userIdKeyResolver, treeEntitySortingService, contentValidationService)
{
_templateService = templateService;
_logger = logger;
@@ -36,45 +38,61 @@ internal sealed class ContentEditingService
return await Task.FromResult(content);
}
public async Task<Attempt<IContent?, ContentEditingOperationStatus>> CreateAsync(ContentCreateModel createModel, Guid userKey)
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateUpdateAsync(IContent content, ContentUpdateModel updateModel)
=> await ValidatePropertiesAsync(updateModel, content.ContentType.Key);
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(ContentCreateModel createModel)
=> await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
public async Task<Attempt<ContentCreateResult, ContentEditingOperationStatus>> CreateAsync(ContentCreateModel createModel, Guid userKey)
{
Attempt<IContent?, ContentEditingOperationStatus> result = await MapCreate(createModel);
Attempt<ContentCreateResult, ContentEditingOperationStatus> result = await MapCreate<ContentCreateResult>(createModel);
if (result.Success == false)
{
return result;
}
IContent content = result.Result!;
ContentEditingOperationStatus operationStatus = await UpdateTemplateAsync(content, createModel.TemplateKey);
if (operationStatus != ContentEditingOperationStatus.Success)
// the create mapping might succeed, but this doesn't mean the model is valid at property level.
// we'll return the actual property validation status if the entire operation succeeds.
ContentEditingOperationStatus validationStatus = result.Status;
ContentValidationResult validationResult = result.Result.ValidationResult;
IContent content = result.Result.Content!;
ContentEditingOperationStatus updateTemplateStatus = await UpdateTemplateAsync(content, createModel.TemplateKey);
if (updateTemplateStatus != ContentEditingOperationStatus.Success)
{
return Attempt.FailWithStatus<IContent?, ContentEditingOperationStatus>(operationStatus, content);
return Attempt.FailWithStatus(updateTemplateStatus, new ContentCreateResult { Content = content });
}
operationStatus = await Save(content, userKey);
return operationStatus == ContentEditingOperationStatus.Success
? Attempt.SucceedWithStatus<IContent?, ContentEditingOperationStatus>(ContentEditingOperationStatus.Success, content)
: Attempt.FailWithStatus<IContent?, ContentEditingOperationStatus>(operationStatus, content);
ContentEditingOperationStatus saveStatus = await Save(content, userKey);
return saveStatus == ContentEditingOperationStatus.Success
? Attempt.SucceedWithStatus(validationStatus, new ContentCreateResult { Content = content, ValidationResult = validationResult })
: Attempt.FailWithStatus(saveStatus, new ContentCreateResult { Content = content });
}
public async Task<Attempt<IContent, ContentEditingOperationStatus>> UpdateAsync(IContent content, ContentUpdateModel updateModel, Guid userKey)
public async Task<Attempt<ContentUpdateResult, ContentEditingOperationStatus>> UpdateAsync(IContent content, ContentUpdateModel updateModel, Guid userKey)
{
Attempt<ContentEditingOperationStatus> result = await MapUpdate(content, updateModel);
Attempt<ContentUpdateResult, ContentEditingOperationStatus> result = await MapUpdate<ContentUpdateResult>(content, updateModel);
if (result.Success == false)
{
return Attempt.FailWithStatus(result.Result, content);
return Attempt.FailWithStatus(result.Status, result.Result);
}
ContentEditingOperationStatus operationStatus = await UpdateTemplateAsync(content, updateModel.TemplateKey);
if (operationStatus != ContentEditingOperationStatus.Success)
// the update mapping might succeed, but this doesn't mean the model is valid at property level.
// we'll return the actual property validation status if the entire operation succeeds.
ContentEditingOperationStatus validationStatus = result.Status;
ContentValidationResult validationResult = result.Result.ValidationResult;
ContentEditingOperationStatus updateTemplateStatus = await UpdateTemplateAsync(content, updateModel.TemplateKey);
if (updateTemplateStatus != ContentEditingOperationStatus.Success)
{
return Attempt.FailWithStatus(operationStatus, content);
return Attempt.FailWithStatus(updateTemplateStatus, new ContentUpdateResult { Content = content });
}
operationStatus = await Save(content, userKey);
return operationStatus == ContentEditingOperationStatus.Success
? Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, content)
: Attempt.FailWithStatus(operationStatus, content);
ContentEditingOperationStatus saveStatus = await Save(content, userKey);
return saveStatus == ContentEditingOperationStatus.Success
? Attempt.SucceedWithStatus(validationStatus, new ContentUpdateResult { Content = content, ValidationResult = validationResult })
: Attempt.FailWithStatus(saveStatus, new ContentUpdateResult { Content = content });
}
public async Task<Attempt<IContent?, ContentEditingOperationStatus>> MoveToRecycleBinAsync(Guid key, Guid userKey)

View File

@@ -20,6 +20,7 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
private readonly ILogger<ContentEditingServiceBase<TContent, TContentType, TContentService, TContentTypeService>> _logger;
private readonly ITreeEntitySortingService _treeEntitySortingService;
private readonly IUserIdKeyResolver _userIdKeyResolver;
private readonly IContentValidationServiceBase<TContentType> _validationService;
protected ContentEditingServiceBase(
TContentService contentService,
@@ -29,13 +30,15 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
ILogger<ContentEditingServiceBase<TContent, TContentType, TContentService, TContentTypeService>> logger,
ICoreScopeProvider scopeProvider,
IUserIdKeyResolver userIdKeyResolver,
ITreeEntitySortingService treeEntitySortingService)
ITreeEntitySortingService treeEntitySortingService,
IContentValidationServiceBase<TContentType> validationService)
{
_propertyEditorCollection = propertyEditorCollection;
_dataTypeService = dataTypeService;
_logger = logger;
_userIdKeyResolver = userIdKeyResolver;
_treeEntitySortingService = treeEntitySortingService;
_validationService = validationService;
CoreScopeProvider = scopeProvider;
ContentService = contentService;
ContentTypeService = contentTypeService;
@@ -61,20 +64,25 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
protected TContentTypeService ContentTypeService { get; }
protected async Task<Attempt<TContent?, ContentEditingOperationStatus>> MapCreate(ContentCreationModelBase contentCreationModelBase)
protected async Task<Attempt<TContentCreateResult, ContentEditingOperationStatus>> MapCreate<TContentCreateResult>(ContentCreationModelBase contentCreationModelBase)
where TContentCreateResult : ContentCreateResultBase<TContent>, new()
{
TContentType? contentType = TryGetAndValidateContentType(contentCreationModelBase.ContentTypeKey, contentCreationModelBase, out ContentEditingOperationStatus operationStatus);
if (contentType == null)
{
return Attempt.FailWithStatus<TContent?, ContentEditingOperationStatus>(operationStatus, null);
return Attempt.FailWithStatus(operationStatus, new TContentCreateResult());
}
TContent? parent = TryGetAndValidateParent(contentCreationModelBase.ParentKey, contentType, out operationStatus);
if (operationStatus != ContentEditingOperationStatus.Success)
{
return Attempt.FailWithStatus<TContent?, ContentEditingOperationStatus>(operationStatus, null);
return Attempt.FailWithStatus(operationStatus, new TContentCreateResult());
}
// NOTE: property level validation errors must NOT fail the update - it must be possible to save invalid properties.
// instead, the error state and validation errors will be communicated in the return value.
Attempt<ContentValidationResult, ContentEditingOperationStatus> validationResult = await ValidatePropertiesAsync(contentCreationModelBase, contentType);
TContent content = New(null, parent?.Id ?? Constants.System.Root, contentType);
if (contentCreationModelBase.Key.HasValue)
{
@@ -84,22 +92,50 @@ internal abstract class ContentEditingServiceBase<TContent, TContentType, TConte
UpdateNames(contentCreationModelBase, content, contentType);
await UpdateExistingProperties(contentCreationModelBase, content, contentType);
return Attempt.SucceedWithStatus<TContent?, ContentEditingOperationStatus>(ContentEditingOperationStatus.Success, content);
return Attempt.SucceedWithStatus(validationResult.Status, new TContentCreateResult { Content = content, ValidationResult = validationResult.Result });
}
protected async Task<Attempt<ContentEditingOperationStatus>> MapUpdate(TContent content, ContentEditingModelBase contentEditingModelBase)
protected async Task<Attempt<TContentUpdateResult, ContentEditingOperationStatus>> MapUpdate<TContentUpdateResult>(TContent content, ContentEditingModelBase contentEditingModelBase)
where TContentUpdateResult : ContentUpdateResultBase<TContent>, new()
{
TContentType? contentType = TryGetAndValidateContentType(content.ContentType.Key, contentEditingModelBase, out ContentEditingOperationStatus operationStatus);
if (contentType == null)
{
return Attempt.Fail(operationStatus);
return Attempt.FailWithStatus(operationStatus, new TContentUpdateResult { Content = content });
}
// NOTE: property level validation errors must NOT fail the update - it must be possible to save invalid properties.
// instead, the error state and validation errors will be communicated in the return value.
Attempt<ContentValidationResult, ContentEditingOperationStatus> validationResult = await ValidatePropertiesAsync(contentEditingModelBase, contentType);
UpdateNames(contentEditingModelBase, content, contentType);
await UpdateExistingProperties(contentEditingModelBase, content, contentType);
RemoveMissingProperties(contentEditingModelBase, content, contentType);
return Attempt.Succeed(ContentEditingOperationStatus.Success);
return Attempt.SucceedWithStatus(validationResult.Status, new TContentUpdateResult { Content = content, ValidationResult = validationResult.Result });
}
protected async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidatePropertiesAsync(
ContentEditingModelBase contentEditingModelBase,
Guid contentTypeKey)
{
TContentType? contentType = await ContentTypeService.GetAsync(contentTypeKey);
if (contentType is null)
{
return Attempt.FailWithStatus(ContentEditingOperationStatus.ContentTypeNotFound, new ContentValidationResult());
}
return await ValidatePropertiesAsync(contentEditingModelBase, contentType);
}
private async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidatePropertiesAsync(
ContentEditingModelBase contentEditingModelBase,
TContentType contentType)
{
ContentValidationResult result = await _validationService.ValidatePropertiesAsync(contentEditingModelBase, contentType);
return result.ValidationErrors.Any() is false
? Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, result)
: Attempt.FailWithStatus(ContentEditingOperationStatus.PropertyValidationError, result);
}
protected async Task<Attempt<TContent?, ContentEditingOperationStatus>> HandleMoveToRecycleBinAsync(Guid key, Guid userKey)

View File

@@ -1,16 +1,20 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentPublishing;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
public class ContentPublishingService : IContentPublishingService
internal sealed class ContentPublishingService : IContentPublishingService
{
private readonly ICoreScopeProvider _coreScopeProvider;
private readonly IContentService _contentService;
private readonly IUserIdKeyResolver _userIdKeyResolver;
public ContentPublishingService(ICoreScopeProvider coreScopeProvider, IContentService contentService, IUserIdKeyResolver userIdKeyResolver)
public ContentPublishingService(
ICoreScopeProvider coreScopeProvider,
IContentService contentService,
IUserIdKeyResolver userIdKeyResolver)
{
_coreScopeProvider = coreScopeProvider;
_contentService = contentService;
@@ -18,13 +22,13 @@ public class ContentPublishingService : IContentPublishingService
}
/// <inheritdoc />
public async Task<Attempt<ContentPublishingOperationStatus>> PublishAsync(Guid key, IEnumerable<string> cultures, Guid userKey)
public async Task<Attempt<ContentPublishingResult, ContentPublishingOperationStatus>> PublishAsync(Guid key, IEnumerable<string> cultures, Guid userKey)
{
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
IContent? content = _contentService.GetById(key);
if (content is null)
{
return Attempt.Fail(ContentPublishingOperationStatus.ContentNotFound);
return Attempt.FailWithStatus(ContentPublishingOperationStatus.ContentNotFound, new ContentPublishingResult());
}
var userId = await _userIdKeyResolver.GetAsync(userKey);
@@ -33,33 +37,59 @@ public class ContentPublishingService : IContentPublishingService
ContentPublishingOperationStatus contentPublishingOperationStatus = ToContentPublishingOperationStatus(result);
return contentPublishingOperationStatus is ContentPublishingOperationStatus.Success
? Attempt.Succeed(ToContentPublishingOperationStatus(result))
: Attempt.Fail(ToContentPublishingOperationStatus(result));
? Attempt.SucceedWithStatus(
ToContentPublishingOperationStatus(result),
new ContentPublishingResult { Content = content })
: Attempt.FailWithStatus(ToContentPublishingOperationStatus(result), new ContentPublishingResult
{
Content = content,
InvalidPropertyAliases = result.InvalidProperties?.Select(property => property.Alias).ToArray()
?? Enumerable.Empty<string>()
});
}
/// <inheritdoc />
public async Task<Attempt<IDictionary<Guid, ContentPublishingOperationStatus>>> PublishBranchAsync(Guid key, IEnumerable<string> cultures, bool force, Guid userKey)
public async Task<Attempt<ContentPublishingBranchResult, ContentPublishingOperationStatus>> PublishBranchAsync(Guid key, IEnumerable<string> cultures, bool force, Guid userKey)
{
using ICoreScope scope = _coreScopeProvider.CreateCoreScope();
IContent? content = _contentService.GetById(key);
if (content is null)
{
var payload = new Dictionary<Guid, ContentPublishingOperationStatus>
{
{ key, ContentPublishingOperationStatus.ContentNotFound },
};
return Attempt<IDictionary<Guid, ContentPublishingOperationStatus>>.Fail(payload);
return Attempt.FailWithStatus(
ContentPublishingOperationStatus.ContentNotFound,
new ContentPublishingBranchResult
{
FailedItems = new[]
{
new ContentPublishingBranchItemResult
{
Key = key, OperationStatus = ContentPublishingOperationStatus.ContentNotFound
}
}
});
}
var userId = await _userIdKeyResolver.GetAsync(userKey);
IEnumerable<PublishResult> result = _contentService.PublishBranch(content, force, cultures.ToArray(), userId);
scope.Complete();
var payloads = result.ToDictionary(r => r.Content.Key, ToContentPublishingOperationStatus);
return payloads.All(p => p.Value is ContentPublishingOperationStatus.Success)
? Attempt<IDictionary<Guid, ContentPublishingOperationStatus>>.Succeed(payloads)
: Attempt<IDictionary<Guid, ContentPublishingOperationStatus>>.Fail(payloads);
var itemResults = result.ToDictionary(r => r.Content.Key, ToContentPublishingOperationStatus);
var branchResult = new ContentPublishingBranchResult
{
Content = content,
SucceededItems = itemResults
.Where(i => i.Value is ContentPublishingOperationStatus.Success)
.Select(i => new ContentPublishingBranchItemResult { Key = i.Key, OperationStatus = i.Value })
.ToArray(),
FailedItems = itemResults
.Where(i => i.Value is not ContentPublishingOperationStatus.Success)
.Select(i => new ContentPublishingBranchItemResult { Key = i.Key, OperationStatus = i.Value })
.ToArray()
};
return branchResult.FailedItems.Any() is false
? Attempt.SucceedWithStatus(ContentPublishingOperationStatus.Success, branchResult)
: Attempt.FailWithStatus(ContentPublishingOperationStatus.FailedBranch, branchResult);
}
/// <inheritdoc />

View File

@@ -0,0 +1,17 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
namespace Umbraco.Cms.Core.Services;
internal sealed class ContentValidationService : ContentValidationServiceBase<IContentType>, IContentValidationService
{
public ContentValidationService(IPropertyValidationService propertyValidationService, ILanguageService languageService)
: base(propertyValidationService, languageService)
{
}
public async Task<ContentValidationResult> ValidatePropertiesAsync(
ContentEditingModelBase contentEditingModelBase,
IContentType contentType)
=> await HandlePropertiesValidationAsync(contentEditingModelBase, contentType);
}

View File

@@ -0,0 +1,126 @@
using System.ComponentModel.DataAnnotations;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Models.ContentEditing.Validation;
using Umbraco.Cms.Core.PropertyEditors.Validation;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Services;
internal abstract class ContentValidationServiceBase<TContentType>
where TContentType : IContentTypeComposition
{
private readonly ILanguageService _languageService;
private readonly IPropertyValidationService _propertyValidationService;
protected ContentValidationServiceBase(
IPropertyValidationService propertyValidationService,
ILanguageService languageService)
{
_propertyValidationService = propertyValidationService;
_languageService = languageService;
}
protected async Task<ContentValidationResult> HandlePropertiesValidationAsync(
ContentEditingModelBase contentEditingModelBase,
TContentType contentType)
{
var validationErrors = new List<PropertyValidationError>();
IPropertyType[] contentTypePropertyTypes = contentType.CompositionPropertyTypes.ToArray();
IPropertyType[] invariantPropertyTypes = contentTypePropertyTypes
.Where(propertyType => propertyType.VariesByNothing())
.ToArray();
IPropertyType[] variantPropertyTypes = contentTypePropertyTypes.Except(invariantPropertyTypes).ToArray();
foreach (IPropertyType propertyType in invariantPropertyTypes)
{
validationErrors.AddRange(ValidateProperty(contentEditingModelBase, propertyType, null, null));
}
if (variantPropertyTypes.Any() is false)
{
return new ContentValidationResult { ValidationErrors = validationErrors };
}
var cultures = (await _languageService.GetAllAsync()).Select(language => language.IsoCode).ToArray();
// 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();
foreach (IPropertyType propertyType in variantPropertyTypes)
{
foreach (var culture in cultures)
{
foreach (var segment in segments)
{
validationErrors.AddRange(ValidateProperty(contentEditingModelBase, propertyType, culture, segment));
}
}
}
return new ContentValidationResult { ValidationErrors = validationErrors };
}
private IEnumerable<PropertyValidationError> ValidateProperty(ContentEditingModelBase contentEditingModelBase, IPropertyType propertyType, string? culture, string? segment)
{
IEnumerable<PropertyValueModel>? properties = culture is null && segment is null
? contentEditingModelBase.InvariantProperties
: contentEditingModelBase
.Variants
.FirstOrDefault(variant => variant.Culture == culture && variant.Segment == segment)?
.Properties;
PropertyValueModel? propertyValueModel = properties?.FirstOrDefault(p => p.Alias == propertyType.Alias);
ValidationResult[] validationResults = _propertyValidationService
.ValidatePropertyValue(propertyType, propertyValueModel?.Value)
.ToArray();
if (validationResults.Any() is false)
{
return Enumerable.Empty<PropertyValidationError>();
}
PropertyValidationError[] validationErrors = validationResults
.SelectMany(validationResult => ExtractPropertyValidationResultJsonPath(validationResult, propertyType.Alias, culture, segment))
.ToArray();
if (validationErrors.Any() is false)
{
validationErrors = new[]
{
new PropertyValidationError
{
JsonPath = string.Empty,
ErrorMessages = validationResults.Select(v => v.ErrorMessage).WhereNotNull().ToArray(),
Alias = propertyType.Alias,
Culture = culture,
Segment = segment
}
};
}
return validationErrors;
}
private IEnumerable<PropertyValidationError> ExtractPropertyValidationResultJsonPath(ValidationResult validationResult, string alias, string? culture, string? segment)
{
if (validationResult is not INestedValidationResults nestedValidationResults)
{
return Enumerable.Empty<PropertyValidationError>();
}
JsonPathValidationError[] results = nestedValidationResults
.ValidationResults
.SelectMany(JsonPathValidator.ExtractJsonPathValidationErrors)
.ToArray();
return results.Select(item => new PropertyValidationError
{
JsonPath = item.JsonPath,
ErrorMessages = item.ErrorMessages.ToArray(),
Alias = alias,
Culture = culture,
Segment = segment
}).ToArray();
}
}

View File

@@ -1,5 +1,6 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Models.ContentEditing.Validation;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
@@ -8,9 +9,13 @@ public interface IContentEditingService
{
Task<IContent?> GetAsync(Guid key);
Task<Attempt<IContent?, ContentEditingOperationStatus>> CreateAsync(ContentCreateModel createModel, Guid userKey);
Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(ContentCreateModel createModel);
Task<Attempt<IContent, ContentEditingOperationStatus>> UpdateAsync(IContent content, ContentUpdateModel updateModel, Guid userKey);
Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateUpdateAsync(IContent content, ContentUpdateModel updateModel);
Task<Attempt<ContentCreateResult, ContentEditingOperationStatus>> CreateAsync(ContentCreateModel createModel, Guid userKey);
Task<Attempt<ContentUpdateResult, ContentEditingOperationStatus>> UpdateAsync(IContent content, ContentUpdateModel updateModel, Guid userKey);
Task<Attempt<IContent?, ContentEditingOperationStatus>> MoveToRecycleBinAsync(Guid key, Guid userKey);

View File

@@ -1,4 +1,5 @@
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Core.Models.ContentPublishing;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
@@ -10,8 +11,8 @@ public interface IContentPublishingService
/// <param name="key">The key of the root content.</param>
/// <param name="cultures">The cultures to publish.</param>
/// <param name="userKey">The identifier of the user performing the operation.</param>
/// <returns>Status of the publish operation.</returns>
Task<Attempt<ContentPublishingOperationStatus>> PublishAsync(Guid key, IEnumerable<string> cultures, Guid userKey);
/// <returns>Result of the publish operation.</returns>
Task<Attempt<ContentPublishingResult, ContentPublishingOperationStatus>> PublishAsync(Guid key, IEnumerable<string> cultures, Guid userKey);
/// <summary>
/// Publishes a content branch.
@@ -20,8 +21,8 @@ public interface IContentPublishingService
/// <param name="cultures">The cultures to publish.</param>
/// <param name="force">A value indicating whether to force-publish content that is not already published.</param>
/// <param name="userKey">The identifier of the user performing the operation.</param>
/// <returns>A dictionary of attempted content item keys and their corresponding publishing status.</returns>
Task<Attempt<IDictionary<Guid, ContentPublishingOperationStatus>>> PublishBranchAsync(Guid key, IEnumerable<string> cultures, bool force, Guid userKey);
/// <returns>Result of the publish operation.</returns>
Task<Attempt<ContentPublishingBranchResult, ContentPublishingOperationStatus>> PublishBranchAsync(Guid key, IEnumerable<string> cultures, bool force, Guid userKey);
/// <summary>
/// Unpublishes a single content item.

View File

@@ -0,0 +1,7 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Services;
internal interface IContentValidationService : IContentValidationServiceBase<IContentType>
{
}

View File

@@ -0,0 +1,10 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
namespace Umbraco.Cms.Core.Services;
internal interface IContentValidationServiceBase<in TContentType>
where TContentType : IContentTypeComposition
{
Task<ContentValidationResult> ValidatePropertiesAsync(ContentEditingModelBase contentEditingModelBase, TContentType contentType);
}

View File

@@ -1,5 +1,6 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Models.ContentEditing.Validation;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
@@ -8,9 +9,13 @@ public interface IMediaEditingService
{
Task<IMedia?> GetAsync(Guid key);
Task<Attempt<IMedia?, ContentEditingOperationStatus>> CreateAsync(MediaCreateModel createModel, Guid userKey);
Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(MediaCreateModel createModel);
Task<Attempt<IMedia, ContentEditingOperationStatus>> UpdateAsync(IMedia media, MediaUpdateModel updateModel, Guid userKey);
Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateUpdateAsync(IMedia media, MediaUpdateModel updateModel);
Task<Attempt<MediaCreateResult, ContentEditingOperationStatus>> CreateAsync(MediaCreateModel createModel, Guid userKey);
Task<Attempt<MediaUpdateResult, ContentEditingOperationStatus>> UpdateAsync(IMedia media, MediaUpdateModel updateModel, Guid userKey);
Task<Attempt<IMedia?, ContentEditingOperationStatus>> MoveToRecycleBinAsync(Guid key, Guid userKey);

View File

@@ -0,0 +1,7 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Services;
internal interface IMediaValidationService : IContentValidationServiceBase<IMediaType>
{
}

View File

@@ -1,6 +1,7 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Models.ContentEditing.Validation;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services.OperationStatus;
@@ -21,8 +22,9 @@ internal sealed class MediaEditingService
ILogger<ContentEditingServiceBase<IMedia, IMediaType, IMediaService, IMediaTypeService>> logger,
ICoreScopeProvider scopeProvider,
IUserIdKeyResolver userIdKeyResolver,
ITreeEntitySortingService treeEntitySortingService)
: base(contentService, contentTypeService, propertyEditorCollection, dataTypeService, logger, scopeProvider, userIdKeyResolver, treeEntitySortingService)
ITreeEntitySortingService treeEntitySortingService,
IMediaValidationService mediaValidationService)
: base(contentService, contentTypeService, propertyEditorCollection, dataTypeService, logger, scopeProvider, userIdKeyResolver, treeEntitySortingService, mediaValidationService)
=> _logger = logger;
public async Task<IMedia?> GetAsync(Guid key)
@@ -31,36 +33,52 @@ internal sealed class MediaEditingService
return await Task.FromResult(media);
}
public async Task<Attempt<IMedia?, ContentEditingOperationStatus>> CreateAsync(MediaCreateModel createModel, Guid userKey)
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateUpdateAsync(IMedia media, MediaUpdateModel updateModel)
=> await ValidatePropertiesAsync(updateModel, media.ContentType.Key);
public async Task<Attempt<ContentValidationResult, ContentEditingOperationStatus>> ValidateCreateAsync(MediaCreateModel createModel)
=> await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey);
public async Task<Attempt<MediaCreateResult, ContentEditingOperationStatus>> CreateAsync(MediaCreateModel createModel, Guid userKey)
{
Attempt<IMedia?, ContentEditingOperationStatus> result = await MapCreate(createModel);
Attempt<MediaCreateResult, ContentEditingOperationStatus> result = await MapCreate<MediaCreateResult>(createModel);
if (result.Success == false)
{
return result;
}
IMedia media = result.Result!;
// the create mapping might succeed, but this doesn't mean the model is valid at property level.
// we'll return the actual property validation status if the entire operation succeeds.
ContentEditingOperationStatus validationStatus = result.Status;
ContentValidationResult validationResult = result.Result.ValidationResult;
IMedia media = result.Result.Content!;
var currentUserId = await GetUserIdAsync(userKey);
ContentEditingOperationStatus operationStatus = Save(media, currentUserId);
return operationStatus == ContentEditingOperationStatus.Success
? Attempt.SucceedWithStatus<IMedia?, ContentEditingOperationStatus>(ContentEditingOperationStatus.Success, media)
: Attempt.FailWithStatus<IMedia?, ContentEditingOperationStatus>(operationStatus, media);
? Attempt.SucceedWithStatus(validationStatus, new MediaCreateResult { Content = media, ValidationResult = validationResult })
: Attempt.FailWithStatus(operationStatus, new MediaCreateResult { Content = media });
}
public async Task<Attempt<IMedia, ContentEditingOperationStatus>> UpdateAsync(IMedia media, MediaUpdateModel updateModel, Guid userKey)
public async Task<Attempt<MediaUpdateResult, ContentEditingOperationStatus>> UpdateAsync(IMedia media, MediaUpdateModel updateModel, Guid userKey)
{
Attempt<ContentEditingOperationStatus> result = await MapUpdate(media, updateModel);
Attempt<MediaUpdateResult, ContentEditingOperationStatus> result = await MapUpdate<MediaUpdateResult>(media, updateModel);
if (result.Success == false)
{
return Attempt.FailWithStatus(result.Result, media);
return result;
}
// the update mapping might succeed, but this doesn't mean the model is valid at property level.
// we'll return the actual property validation status if the entire operation succeeds.
ContentEditingOperationStatus validationStatus = result.Status;
ContentValidationResult validationResult = result.Result.ValidationResult;
var currentUserId = await GetUserIdAsync(userKey);
ContentEditingOperationStatus operationStatus = Save(media, currentUserId);
return operationStatus == ContentEditingOperationStatus.Success
? Attempt.SucceedWithStatus(ContentEditingOperationStatus.Success, media)
: Attempt.FailWithStatus(operationStatus, media);
? Attempt.SucceedWithStatus(validationStatus, new MediaUpdateResult { Content = media, ValidationResult = validationResult })
: Attempt.FailWithStatus(operationStatus, new MediaUpdateResult { Content = media });
}
public async Task<Attempt<IMedia?, ContentEditingOperationStatus>> MoveToRecycleBinAsync(Guid key, Guid userKey)

View File

@@ -0,0 +1,17 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
namespace Umbraco.Cms.Core.Services;
internal sealed class MediaValidationService : ContentValidationServiceBase<IMediaType>, IMediaValidationService
{
public MediaValidationService(IPropertyValidationService propertyValidationService, ILanguageService languageService)
: base(propertyValidationService, languageService)
{
}
public async Task<ContentValidationResult> ValidatePropertiesAsync(
ContentEditingModelBase contentEditingModelBase,
IMediaType mediaType)
=> await HandlePropertiesValidationAsync(contentEditingModelBase, mediaType);
}

View File

@@ -16,5 +16,6 @@ public enum ContentEditingOperationStatus
InTrash,
NotInTrash,
SortingInvalid,
PropertyValidationError,
Unknown
}

View File

@@ -16,6 +16,7 @@ public enum ContentPublishingOperationStatus
PathNotPublished,
ConcurrencyViolation,
UnsavedChanges,
FailedBranch,
Failed, // unspecified failure (can happen on unpublish at the time of writing)
Unknown
}

View File

@@ -10,18 +10,25 @@ public class PropertyValidationService : IPropertyValidationService
{
private readonly IDataTypeService _dataTypeService;
private readonly PropertyEditorCollection _propertyEditors;
private readonly ILocalizedTextService _textService;
private readonly IValueEditorCache _valueEditorCache;
[Obsolete($"Use the constructor that does not accept {nameof(ILocalizedTextService)}. Will be removed in V15.")]
public PropertyValidationService(
PropertyEditorCollection propertyEditors,
IDataTypeService dataTypeService,
ILocalizedTextService textService,
IValueEditorCache valueEditorCache)
: this(propertyEditors, dataTypeService, valueEditorCache)
{
}
public PropertyValidationService(
PropertyEditorCollection propertyEditors,
IDataTypeService dataTypeService,
IValueEditorCache valueEditorCache)
{
_propertyEditors = propertyEditors;
_dataTypeService = dataTypeService;
_textService = textService;
_valueEditorCache = valueEditorCache;
}
@@ -63,11 +70,8 @@ public class PropertyValidationService : IPropertyValidationService
{
// Retrieve default messages used for required and regex validatation. We'll replace these
// if set with custom ones if they've been provided for a given property.
var requiredDefaultMessages = new[]
{
_textService.Localize("validation", "invalidNull"), _textService.Localize("validation", "invalidEmpty"),
};
var formatDefaultMessages = new[] { _textService.Localize("validation", "invalidPattern") };
var requiredDefaultMessages = new[] { Constants.Validation.ErrorMessages.Properties.Missing };
var formatDefaultMessages = new[] { Constants.Validation.ErrorMessages.Properties.PatternMismatch };
IDataValueEditor valueEditor = _valueEditorCache.GetValueEditor(editor, dataType);
foreach (ValidationResult validationResult in valueEditor.Validate(postedValue, isRequired, validationRegExp))