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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 />
|
||||
|
||||
17
src/Umbraco.Core/Services/ContentValidationService.cs
Normal file
17
src/Umbraco.Core/Services/ContentValidationService.cs
Normal 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);
|
||||
}
|
||||
126
src/Umbraco.Core/Services/ContentValidationServiceBase.cs
Normal file
126
src/Umbraco.Core/Services/ContentValidationServiceBase.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
7
src/Umbraco.Core/Services/IContentValidationService.cs
Normal file
7
src/Umbraco.Core/Services/IContentValidationService.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using Umbraco.Cms.Core.Models;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
|
||||
internal interface IContentValidationService : IContentValidationServiceBase<IContentType>
|
||||
{
|
||||
}
|
||||
10
src/Umbraco.Core/Services/IContentValidationServiceBase.cs
Normal file
10
src/Umbraco.Core/Services/IContentValidationServiceBase.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
7
src/Umbraco.Core/Services/IMediaValidationService.cs
Normal file
7
src/Umbraco.Core/Services/IMediaValidationService.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using Umbraco.Cms.Core.Models;
|
||||
|
||||
namespace Umbraco.Cms.Core.Services;
|
||||
|
||||
internal interface IMediaValidationService : IContentValidationServiceBase<IMediaType>
|
||||
{
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
17
src/Umbraco.Core/Services/MediaValidationService.cs
Normal file
17
src/Umbraco.Core/Services/MediaValidationService.cs
Normal 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);
|
||||
}
|
||||
@@ -16,5 +16,6 @@ public enum ContentEditingOperationStatus
|
||||
InTrash,
|
||||
NotInTrash,
|
||||
SortingInvalid,
|
||||
PropertyValidationError,
|
||||
Unknown
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ public enum ContentPublishingOperationStatus
|
||||
PathNotPublished,
|
||||
ConcurrencyViolation,
|
||||
UnsavedChanges,
|
||||
FailedBranch,
|
||||
Failed, // unspecified failure (can happen on unpublish at the time of writing)
|
||||
Unknown
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user