diff --git a/src/Umbraco.Cms.Api.Common/Builders/ProblemDetailsBuilder.cs b/src/Umbraco.Cms.Api.Common/Builders/ProblemDetailsBuilder.cs index d3897d5377..ddb252b0a1 100644 --- a/src/Umbraco.Cms.Api.Common/Builders/ProblemDetailsBuilder.cs +++ b/src/Umbraco.Cms.Api.Common/Builders/ProblemDetailsBuilder.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Umbraco.Extensions; namespace Umbraco.Cms.Api.Common.Builders; @@ -8,6 +9,8 @@ public class ProblemDetailsBuilder private string? _title; private string? _detail; private string? _type; + private string? _operationStatus; + private IDictionary? _extensions; public ProblemDetailsBuilder WithTitle(string title) { @@ -27,11 +30,45 @@ public class ProblemDetailsBuilder return this; } - public ProblemDetails Build() => - new() + public ProblemDetailsBuilder WithOperationStatus(TEnum operationStatus) + where TEnum : Enum + { + _operationStatus = operationStatus.ToString(); + return this; + } + + public ProblemDetailsBuilder WithRequestModelErrors(IDictionary errors) + => WithExtension(nameof(HttpValidationProblemDetails.Errors).ToFirstLowerInvariant(), errors); + + public ProblemDetailsBuilder WithExtension(string key, object value) + { + _extensions ??= new Dictionary(); + _extensions[key] = value; + return this; + } + + public ProblemDetails Build() + { + var problemDetails = new ProblemDetails { Title = _title, Detail = _detail, Type = _type ?? "Error", }; + + if (_operationStatus is not null) + { + problemDetails.Extensions["operationStatus"] = _operationStatus; + } + + if (_extensions is not null) + { + foreach (KeyValuePair extension in _extensions) + { + problemDetails.Extensions[extension.Key] = extension.Value; + } + } + + return problemDetails; + } } diff --git a/src/Umbraco.Cms.Api.Management/Content/ContentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Content/ContentControllerBase.cs index 2c796f4fb1..acfd1f641e 100644 --- a/src/Umbraco.Cms.Api.Management/Content/ContentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Content/ContentControllerBase.cs @@ -1,173 +1,237 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Api.Management.Controllers; +using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.ContentEditing.Validation; +using Umbraco.Cms.Core.Models.ContentPublishing; using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Content; public class ContentControllerBase : ManagementApiControllerBase { - protected IActionResult ContentEditingOperationStatusResult(ContentEditingOperationStatus status) => - status switch + protected IActionResult ContentEditingOperationStatusResult(ContentEditingOperationStatus status) + => OperationStatusResult(status, problemDetailsBuilder => status switch { - ContentEditingOperationStatus.CancelledByNotification => BadRequest(new ProblemDetailsBuilder() + ContentEditingOperationStatus.CancelledByNotification => BadRequest(problemDetailsBuilder .WithTitle("Cancelled by notification") .WithDetail("A notification handler prevented the content operation.") .Build()), - ContentEditingOperationStatus.ContentTypeNotFound => NotFound(new ProblemDetailsBuilder() + ContentEditingOperationStatus.ContentTypeNotFound => NotFound(problemDetailsBuilder .WithTitle("The requested content could not be found") .Build()), - ContentEditingOperationStatus.ContentTypeCultureVarianceMismatch => BadRequest(new ProblemDetailsBuilder() + ContentEditingOperationStatus.ContentTypeCultureVarianceMismatch => BadRequest(problemDetailsBuilder .WithTitle("Content type culture variance mismatch") .WithDetail("The content type variance did not match that of the passed content data.") .Build()), - ContentEditingOperationStatus.NotFound => NotFound(new ProblemDetailsBuilder() - .WithTitle("The content could not be found") - .Build()), - ContentEditingOperationStatus.ParentNotFound => NotFound(new ProblemDetailsBuilder() - .WithTitle("The targeted content parent could not be found") - .Build()), - ContentEditingOperationStatus.ParentInvalid => BadRequest(new ProblemDetailsBuilder() + ContentEditingOperationStatus.NotFound => NotFound(problemDetailsBuilder + .WithTitle("The content could not be found") + .Build()), + ContentEditingOperationStatus.ParentNotFound => NotFound(problemDetailsBuilder + .WithTitle("The targeted content parent could not be found") + .Build()), + ContentEditingOperationStatus.ParentInvalid => BadRequest(problemDetailsBuilder .WithTitle("Invalid parent") .WithDetail("The targeted parent was not valid for the operation.") .Build()), - ContentEditingOperationStatus.NotAllowed => BadRequest(new ProblemDetailsBuilder() + ContentEditingOperationStatus.NotAllowed => BadRequest(problemDetailsBuilder .WithTitle("Operation not permitted") - .WithDetail("The attempted operation was not permitted, likely due to a permission/configuration mismatch with the operation.") + .WithDetail( + "The attempted operation was not permitted, likely due to a permission/configuration mismatch with the operation.") .Build()), - ContentEditingOperationStatus.TemplateNotFound => NotFound(new ProblemDetailsBuilder() - .WithTitle("The template could not be found") - .Build()), - ContentEditingOperationStatus.TemplateNotAllowed => BadRequest(new ProblemDetailsBuilder() + ContentEditingOperationStatus.TemplateNotFound => NotFound(problemDetailsBuilder + .WithTitle("The template could not be found") + .Build()), + ContentEditingOperationStatus.TemplateNotAllowed => BadRequest(problemDetailsBuilder .WithTitle("Template not allowed") .WithDetail("The selected template was not allowed for the operation.") .Build()), - ContentEditingOperationStatus.PropertyTypeNotFound => NotFound(new ProblemDetailsBuilder() - .WithTitle("One or more property types could not be found") - .Build()), - ContentEditingOperationStatus.InTrash => BadRequest(new ProblemDetailsBuilder() + ContentEditingOperationStatus.PropertyTypeNotFound => NotFound(problemDetailsBuilder + .WithTitle("One or more property types could not be found") + .Build()), + ContentEditingOperationStatus.InTrash => BadRequest(problemDetailsBuilder .WithTitle("Content is in the recycle bin") .WithDetail("Could not perform the operation because the targeted content was in the recycle bin.") .Build()), - ContentEditingOperationStatus.NotInTrash => BadRequest(new ProblemDetailsBuilder() + ContentEditingOperationStatus.NotInTrash => BadRequest(problemDetailsBuilder .WithTitle("Content is not in the recycle bin") .WithDetail("The attempted operation requires the targeted content to be in the recycle bin.") .Build()), - ContentEditingOperationStatus.SortingInvalid => BadRequest(new ProblemDetailsBuilder() + ContentEditingOperationStatus.SortingInvalid => BadRequest(problemDetailsBuilder .WithTitle("Invalid sorting options") .WithDetail("The supplied sorting operations were invalid. Additional details can be found in the log.") .Build()), - ContentEditingOperationStatus.Unknown => StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetailsBuilder() - .WithTitle("Unknown error. Please see the log for more details.") - .Build()), - _ => StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetailsBuilder() + ContentEditingOperationStatus.Unknown => StatusCode(StatusCodes.Status500InternalServerError, + problemDetailsBuilder + .WithTitle("Unknown error. Please see the log for more details.") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder .WithTitle("Unknown content operation status.") .Build()), - }; + }); - protected IActionResult ContentPublishingOperationStatusResult(ContentPublishingOperationStatus status) => - status switch + protected IActionResult ContentEditingOperationStatusResult( + ContentEditingOperationStatus status, + TContentModelBase requestModel, + ContentValidationResult validationResult) + where TContentModelBase : ContentModelBase + where TValueModel : ValueModelBase + where TVariantModel : VariantModelBase + { + if (status is not ContentEditingOperationStatus.PropertyValidationError) { - ContentPublishingOperationStatus.ContentNotFound => NotFound(new ProblemDetailsBuilder() + return ContentEditingOperationStatusResult(status); + } + + var errors = new SortedDictionary(); + var missingPropertyAliases = new List(); + foreach (PropertyValidationError validationError in validationResult.ValidationErrors) + { + TValueModel? requestValue = requestModel.Values.FirstOrDefault(value => + value.Alias == validationError.Alias + && value.Culture == validationError.Culture + && value.Segment == validationError.Segment); + if (requestValue is null) + { + missingPropertyAliases.Add(validationError.Alias); + continue; + } + + var index = requestModel.Values.IndexOf(requestValue); + var key = $"$.{nameof(ContentModelBase.Values).ToFirstLowerInvariant()}[{index}].{nameof(ValueModelBase.Value).ToFirstLowerInvariant()}{validationError.JsonPath}"; + errors.Add(key, validationError.ErrorMessages); + } + + return OperationStatusResult(status, problemDetailsBuilder + => BadRequest(problemDetailsBuilder + .WithTitle("Validation failed") + .WithDetail("One or more properties did not pass validation") + .WithRequestModelErrors(errors) + .WithExtension("missingProperties", missingPropertyAliases.ToArray()) + .Build())); + } + + protected IActionResult ContentPublishingOperationStatusResult( + ContentPublishingOperationStatus status, + IEnumerable? invalidPropertyAliases = null, + IEnumerable? failedBranchItems = null) + => OperationStatusResult(status, problemDetailsBuilder => status switch + { + ContentPublishingOperationStatus.ContentNotFound => NotFound(problemDetailsBuilder .WithTitle("The requested content could not be found") .Build()), - ContentPublishingOperationStatus.CancelledByEvent => BadRequest(new ProblemDetailsBuilder() + ContentPublishingOperationStatus.CancelledByEvent => BadRequest(problemDetailsBuilder .WithTitle("Publish cancelled by event") .WithDetail("The publish operation was cancelled by an event.") .Build()), - ContentPublishingOperationStatus.ContentInvalid => BadRequest(new ProblemDetailsBuilder() + ContentPublishingOperationStatus.ContentInvalid => BadRequest(problemDetailsBuilder .WithTitle("Invalid content") .WithDetail("The specified content had an invalid configuration.") + .WithExtension("invalidProperties", invalidPropertyAliases ?? Enumerable.Empty()) .Build()), - ContentPublishingOperationStatus.NothingToPublish => BadRequest(new ProblemDetailsBuilder() + ContentPublishingOperationStatus.NothingToPublish => BadRequest(problemDetailsBuilder .WithTitle("Nothing to publish") .WithDetail("None of the specified cultures needed publishing.") .Build()), - ContentPublishingOperationStatus.MandatoryCultureMissing => BadRequest(new ProblemDetailsBuilder() + ContentPublishingOperationStatus.MandatoryCultureMissing => BadRequest(problemDetailsBuilder .WithTitle("Mandatory culture missing") .WithDetail("Must include all mandatory cultures when publishing.") .Build()), - ContentPublishingOperationStatus.HasExpired => BadRequest(new ProblemDetailsBuilder() + ContentPublishingOperationStatus.HasExpired => BadRequest(problemDetailsBuilder .WithTitle("Content expired") .WithDetail("Could not publish the content because it was expired.") .Build()), - ContentPublishingOperationStatus.CultureHasExpired => BadRequest(new ProblemDetailsBuilder() + ContentPublishingOperationStatus.CultureHasExpired => BadRequest(problemDetailsBuilder .WithTitle("Content culture expired") .WithDetail("Could not publish the content because some of the specified cultures were expired.") .Build()), - ContentPublishingOperationStatus.AwaitingRelease => BadRequest(new ProblemDetailsBuilder() + ContentPublishingOperationStatus.AwaitingRelease => BadRequest(problemDetailsBuilder .WithTitle("Content awaiting release") .WithDetail("Could not publish the content because it was awaiting release.") .Build()), - ContentPublishingOperationStatus.CultureAwaitingRelease => BadRequest(new ProblemDetailsBuilder() + ContentPublishingOperationStatus.CultureAwaitingRelease => BadRequest(problemDetailsBuilder .WithTitle("Content culture awaiting release") - .WithDetail("Could not publish the content because some of the specified cultures were awaiting release.") + .WithDetail( + "Could not publish the content because some of the specified cultures were awaiting release.") .Build()), - ContentPublishingOperationStatus.InTrash => BadRequest(new ProblemDetailsBuilder() + ContentPublishingOperationStatus.InTrash => BadRequest(problemDetailsBuilder .WithTitle("Content in the recycle bin") .WithDetail("Could not publish the content because it was in the recycle bin.") .Build()), - ContentPublishingOperationStatus.PathNotPublished => BadRequest(new ProblemDetailsBuilder() + ContentPublishingOperationStatus.PathNotPublished => BadRequest(problemDetailsBuilder .WithTitle("Parent not published") .WithDetail("Could not publish the content because its parent was not published.") .Build()), - ContentPublishingOperationStatus.ConcurrencyViolation => BadRequest(new ProblemDetailsBuilder() + ContentPublishingOperationStatus.ConcurrencyViolation => BadRequest(problemDetailsBuilder .WithTitle("Concurrency violation detected") .WithDetail("An attempt was made to publish a version older than the latest version.") .Build()), - ContentPublishingOperationStatus.UnsavedChanges => BadRequest(new ProblemDetailsBuilder() + ContentPublishingOperationStatus.UnsavedChanges => BadRequest(problemDetailsBuilder .WithTitle("Unsaved changes") - .WithDetail("Could not publish the content because it had unsaved changes. Make sure to save all changes before attempting a publish.") + .WithDetail( + "Could not publish the content because it had unsaved changes. Make sure to save all changes before attempting a publish.") .Build()), - ContentPublishingOperationStatus.Failed => BadRequest(new ProblemDetailsBuilder() + ContentPublishingOperationStatus.FailedBranch => BadRequest(problemDetailsBuilder + .WithTitle("Failed branch operation") + .WithDetail("One or more items in the branch could not complete the operation.") + .WithExtension("failedBranchItems", failedBranchItems?.Select(item => new DocumentPublishBranchItemResult + { + Id = item.Key, + OperationStatus = item.OperationStatus + }) ?? Enumerable.Empty()) + .Build()), + ContentPublishingOperationStatus.Failed => BadRequest(problemDetailsBuilder .WithTitle("Publish or unpublish failed") - .WithDetail("An unspecified error occurred while (un)publishing. Please check the logs for additional information.") + .WithDetail( + "An unspecified error occurred while (un)publishing. Please check the logs for additional information.") .Build()), _ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown content operation status."), - }; + }); - protected IActionResult ContentCreatingOperationStatusResult(ContentCreatingOperationStatus status) => - status switch + protected IActionResult ContentCreatingOperationStatusResult(ContentCreatingOperationStatus status) + => OperationStatusResult(status, problemDetailsBuilder => status switch { - ContentCreatingOperationStatus.NotFound => NotFound(new ProblemDetailsBuilder() - .WithTitle("The content type could not be found") - .Build()), - _ => StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetailsBuilder() + ContentCreatingOperationStatus.NotFound => NotFound(problemDetailsBuilder + .WithTitle("The content type could not be found") + .Build()), + _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder .WithTitle("Unknown content operation status.") .Build()), - }; + }); - protected IActionResult PublicAccessOperationStatusResult(PublicAccessOperationStatus status) => - status switch + protected IActionResult PublicAccessOperationStatusResult(PublicAccessOperationStatus status) + => OperationStatusResult(status, problemDetailsBuilder => status switch { - PublicAccessOperationStatus.ContentNotFound => NotFound(new ProblemDetailsBuilder() - .WithTitle("The content could not be found") - .Build()), - PublicAccessOperationStatus.ErrorNodeNotFound => NotFound(new ProblemDetailsBuilder() - .WithTitle("The error page could not be found") - .Build()), - PublicAccessOperationStatus.LoginNodeNotFound => NotFound(new ProblemDetailsBuilder() - .WithTitle("The login page could not be found") - .Build()), - PublicAccessOperationStatus.NoAllowedEntities => BadRequest(new ProblemDetailsBuilder() + PublicAccessOperationStatus.ContentNotFound => NotFound(problemDetailsBuilder + .WithTitle("The content could not be found") + .Build()), + PublicAccessOperationStatus.ErrorNodeNotFound => NotFound(problemDetailsBuilder + .WithTitle("The error page could not be found") + .Build()), + PublicAccessOperationStatus.LoginNodeNotFound => NotFound(problemDetailsBuilder + .WithTitle("The login page could not be found") + .Build()), + PublicAccessOperationStatus.NoAllowedEntities => BadRequest(problemDetailsBuilder .WithTitle("No allowed entities given") .WithDetail("Both MemberGroups and Members were empty, thus no entities can be allowed.") .Build()), - PublicAccessOperationStatus.CancelledByNotification => BadRequest(new ProblemDetailsBuilder() + PublicAccessOperationStatus.CancelledByNotification => BadRequest(problemDetailsBuilder .WithTitle("Request cancelled by notification") .WithDetail("The request to save a public access entry was cancelled by a notification handler.") .Build()), - PublicAccessOperationStatus.AmbiguousRule => BadRequest(new ProblemDetailsBuilder() + PublicAccessOperationStatus.AmbiguousRule => BadRequest(problemDetailsBuilder .WithTitle("Ambiguous Rule") .WithDetail("The specified rule is ambiguous, because both member groups and member names were given.") .Build()), - PublicAccessOperationStatus.EntryNotFound => BadRequest(new ProblemDetailsBuilder() + PublicAccessOperationStatus.EntryNotFound => BadRequest(problemDetailsBuilder .WithTitle("Entry not found") .WithDetail("The specified entry was not found.") .Build()), - _ => StatusCode(StatusCodes.Status500InternalServerError, new ProblemDetailsBuilder() + _ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder .WithTitle("Unknown content operation status.") .Build()), - }; + }); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateDocumentController.cs index b55befc3e5..042b030438 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateDocumentController.cs @@ -3,24 +3,18 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Factories; -using Umbraco.Cms.Api.Management.Security.Authorization.Content; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Actions; -using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; -using Umbraco.Cms.Web.Common.Authorization; -using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Controllers.Document; [ApiVersion("1.0")] -public class CreateDocumentController : DocumentControllerBase +public class CreateDocumentController : CreateDocumentControllerBase { - private readonly IAuthorizationService _authorizationService; private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; private readonly IContentEditingService _contentEditingService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; @@ -30,8 +24,8 @@ public class CreateDocumentController : DocumentControllerBase IDocumentEditingPresentationFactory documentEditingPresentationFactory, IContentEditingService contentEditingService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : base(authorizationService) { - _authorizationService = authorizationService; _documentEditingPresentationFactory = documentEditingPresentationFactory; _contentEditingService = contentEditingService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; @@ -40,29 +34,17 @@ public class CreateDocumentController : DocumentControllerBase [HttpPost] [MapToApiVersion("1.0")] [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task Create(CreateDocumentRequestModel requestModel) - { - AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( - User, - ContentPermissionResource.WithKeys( - ActionNew.ActionLetter, - requestModel.Parent?.Id, - requestModel.Variants - .Where(v => v.Culture is not null) - .Select(v => v.Culture!)), - AuthorizationPolicies.ContentPermissionByResource); - - if (!authorizationResult.Succeeded) + => await HandleRequest(requestModel, async () => { - return Forbidden(); - } + ContentCreateModel model = _documentEditingPresentationFactory.MapCreateModel(requestModel); + Attempt result = + await _contentEditingService.CreateAsync(model, CurrentUserKey(_backOfficeSecurityAccessor)); - ContentCreateModel model = _documentEditingPresentationFactory.MapCreateModel(requestModel); - Attempt result = await _contentEditingService.CreateAsync(model, CurrentUserKey(_backOfficeSecurityAccessor)); - - return result.Success - ? CreatedAtId(controller => nameof(controller.ByKey), result.Result!.Key) - : ContentEditingOperationStatusResult(result.Status); - } + return result.Success + ? CreatedAtId(controller => nameof(controller.ByKey), result.Result.Content!.Key) + : ContentEditingOperationStatusResult(result.Status); + }); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateDocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateDocumentControllerBase.cs new file mode 100644 index 0000000000..faa0de8bcb --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/CreateDocumentControllerBase.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Security.Authorization.Content; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +public abstract class CreateDocumentControllerBase : DocumentControllerBase +{ + private readonly IAuthorizationService _authorizationService; + + protected CreateDocumentControllerBase(IAuthorizationService authorizationService) + => _authorizationService = authorizationService; + + protected async Task HandleRequest(CreateDocumentRequestModel requestModel, Func> authorizedHandler) + { + IEnumerable cultures = requestModel.Variants + .Where(v => v.Culture is not null) + .Select(v => v.Culture!); + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ContentPermissionResource.WithKeys(ActionNew.ActionLetter, requestModel.Parent?.Id, cultures), + AuthorizationPolicies.ContentPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + return await authorizedHandler(); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs index 6c3b1dd98e..1675d5911b 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/DocumentControllerBase.cs @@ -1,9 +1,12 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Api.Management.Content; using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Web.Common.Authorization; namespace Umbraco.Cms.Api.Management.Controllers.Document; @@ -14,7 +17,16 @@ namespace Umbraco.Cms.Api.Management.Controllers.Document; [Authorize(Policy = "New" + AuthorizationPolicies.TreeAccessDocuments)] public abstract class DocumentControllerBase : ContentControllerBase { - protected IActionResult DocumentNotFound() => NotFound(new ProblemDetailsBuilder() - .WithTitle("The requested Document could not be found") - .Build()); + protected IActionResult DocumentNotFound() + => OperationStatusResult(ContentEditingOperationStatus.NotFound, problemDetailsBuilder + => NotFound(problemDetailsBuilder + .WithTitle("The requested Document could not be found") + .Build())); + + protected IActionResult DocumentEditingOperationStatusResult( + ContentEditingOperationStatus status, + TContentModelBase requestModel, + ContentValidationResult validationResult) + where TContentModelBase : ContentModelBase + => ContentEditingOperationStatusResult(status, requestModel, validationResult); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentController.cs index 5b40449be5..933568871a 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentController.cs @@ -7,6 +7,7 @@ using Umbraco.Cms.Api.Management.Security.Authorization.UserGroup; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models.ContentPublishing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; @@ -49,12 +50,12 @@ public class PublishDocumentController : DocumentControllerBase return Forbidden(); } - Attempt attempt = await _contentPublishingService.PublishAsync( + Attempt attempt = await _contentPublishingService.PublishAsync( id, requestModel.Cultures, CurrentUserKey(_backOfficeSecurityAccessor)); return attempt.Success ? Ok() - : ContentPublishingOperationStatusResult(attempt.Result); + : ContentPublishingOperationStatusResult(attempt.Status, invalidPropertyAliases: attempt.Result.InvalidPropertyAliases); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs index 75a0b8dc80..9b457d6565 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/PublishDocumentWithDescendantsController.cs @@ -6,6 +6,7 @@ using Umbraco.Cms.Api.Management.Security.Authorization.Content; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models.ContentPublishing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; @@ -48,17 +49,14 @@ public class PublishDocumentWithDescendantsController : DocumentControllerBase return Forbidden(); } - Attempt> attempt = await _contentPublishingService.PublishBranchAsync( + Attempt attempt = await _contentPublishingService.PublishBranchAsync( id, requestModel.Cultures, requestModel.IncludeUnpublishedDescendants, CurrentUserKey(_backOfficeSecurityAccessor)); - // FIXME: when we get to implement proper validation handling, this should return a collection of status codes by key (based on attempt.Result) return attempt.Success ? Ok() - : ContentPublishingOperationStatusResult( - attempt.Result?.Values.FirstOrDefault(r => r is not ContentPublishingOperationStatus.Success) - ?? throw new NotSupportedException("The attempt was not successful - at least one result value should be unsuccessful too")); + : ContentPublishingOperationStatusResult(attempt.Status, failedBranchItems: attempt.Result.FailedItems); } } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentController.cs index 059040ce89..25cf16598f 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentController.cs @@ -3,24 +3,18 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Factories; -using Umbraco.Cms.Api.Management.Security.Authorization.Content; using Umbraco.Cms.Api.Management.ViewModels.Document; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Actions; -using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; -using Umbraco.Cms.Web.Common.Authorization; -using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Controllers.Document; [ApiVersion("1.0")] -public class UpdateDocumentController : DocumentControllerBase +public class UpdateDocumentController : UpdateDocumentControllerBase { - private readonly IAuthorizationService _authorizationService; private readonly IContentEditingService _contentEditingService; private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; @@ -30,8 +24,8 @@ public class UpdateDocumentController : DocumentControllerBase IContentEditingService contentEditingService, IDocumentEditingPresentationFactory documentEditingPresentationFactory, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : base(authorizationService, contentEditingService) { - _authorizationService = authorizationService; _contentEditingService = contentEditingService; _documentEditingPresentationFactory = documentEditingPresentationFactory; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; @@ -40,35 +34,17 @@ public class UpdateDocumentController : DocumentControllerBase [HttpPut("{id:guid}")] [MapToApiVersion("1.0")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task Update(Guid id, UpdateDocumentRequestModel requestModel) - { - AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( - User, - ContentPermissionResource.WithKeys( - ActionUpdate.ActionLetter, - id, - requestModel.Variants - .Where(v => v.Culture is not null) - .Select(v => v.Culture!)), - AuthorizationPolicies.ContentPermissionByResource); - - if (!authorizationResult.Succeeded) + => await HandleRequest(id, requestModel, async content => { - return Forbidden(); - } + ContentUpdateModel model = _documentEditingPresentationFactory.MapUpdateModel(requestModel); + Attempt result = + await _contentEditingService.UpdateAsync(content, model, CurrentUserKey(_backOfficeSecurityAccessor)); - IContent? content = await _contentEditingService.GetAsync(id); - if (content == null) - { - return DocumentNotFound(); - } - - ContentUpdateModel model = _documentEditingPresentationFactory.MapUpdateModel(requestModel); - Attempt result = await _contentEditingService.UpdateAsync(content, model, CurrentUserKey(_backOfficeSecurityAccessor)); - - return result.Success - ? Ok() - : ContentEditingOperationStatusResult(result.Status); - } + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Status); + }); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentControllerBase.cs new file mode 100644 index 0000000000..385d6f44cb --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/UpdateDocumentControllerBase.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Security.Authorization.Content; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core.Actions; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +public abstract class UpdateDocumentControllerBase : DocumentControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IContentEditingService _contentEditingService; + + protected UpdateDocumentControllerBase(IAuthorizationService authorizationService, IContentEditingService contentEditingService) + { + _authorizationService = authorizationService; + _contentEditingService = contentEditingService; + } + + protected async Task HandleRequest(Guid id, UpdateDocumentRequestModel requestModel, Func> authorizedHandler) + { + IEnumerable cultures = requestModel.Variants + .Where(v => v.Culture is not null) + .Select(v => v.Culture!); + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + ContentPermissionResource.WithKeys(ActionUpdate.ActionLetter, id, cultures), + AuthorizationPolicies.ContentPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + IContent? content = await _contentEditingService.GetAsync(id); + if (content is null) + { + return DocumentNotFound(); + } + + return await authorizedHandler(content); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateCreateDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateCreateDocumentController.cs new file mode 100644 index 0000000000..f85061dbe8 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateCreateDocumentController.cs @@ -0,0 +1,45 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +[ApiVersion("1.0")] +public class ValidateCreateDocumentController : CreateDocumentControllerBase +{ + private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; + private readonly IContentEditingService _contentEditingService; + + public ValidateCreateDocumentController( + IAuthorizationService authorizationService, + IDocumentEditingPresentationFactory documentEditingPresentationFactory, + IContentEditingService contentEditingService) + : base(authorizationService) + { + _documentEditingPresentationFactory = documentEditingPresentationFactory; + _contentEditingService = contentEditingService; + } + + [HttpPost("validate")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Validate(CreateDocumentRequestModel requestModel) + => await HandleRequest(requestModel, async () => + { + ContentCreateModel model = _documentEditingPresentationFactory.MapCreateModel(requestModel); + Attempt result = await _contentEditingService.ValidateCreateAsync(model); + + return result.Success + ? Ok() + : DocumentEditingOperationStatusResult(result.Status, requestModel, result.Result); + }); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs new file mode 100644 index 0000000000..25ccbfbb87 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Document/ValidateUpdateDocumentController.cs @@ -0,0 +1,45 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Document; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Document; + +[ApiVersion("1.0")] +public class ValidateUpdateDocumentController : UpdateDocumentControllerBase +{ + private readonly IContentEditingService _contentEditingService; + private readonly IDocumentEditingPresentationFactory _documentEditingPresentationFactory; + + public ValidateUpdateDocumentController( + IAuthorizationService authorizationService, + IContentEditingService contentEditingService, + IDocumentEditingPresentationFactory documentEditingPresentationFactory) + : base(authorizationService, contentEditingService) + { + _contentEditingService = contentEditingService; + _documentEditingPresentationFactory = documentEditingPresentationFactory; + } + + [HttpPut("{id:guid}/validate")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Validate(Guid id, UpdateDocumentRequestModel requestModel) + => await HandleRequest(id, requestModel, async content => + { + ContentUpdateModel model = _documentEditingPresentationFactory.MapUpdateModel(requestModel); + Attempt result = await _contentEditingService.ValidateUpdateAsync(content, model); + + return result.Success + ? Ok() + : DocumentEditingOperationStatusResult(result.Status, requestModel, result.Result); + }); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs index e6636b34cc..400ebd7cbf 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/ManagementApiControllerBase.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.Attributes; +using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Api.Common.Filters; using Umbraco.Cms.Api.Common.Mvc.ActionResults; using Umbraco.Cms.Api.Management.DependencyInjection; @@ -52,4 +53,8 @@ public abstract class ManagementApiControllerBase : Controller, IUmbracoFeature /// // Duplicate code copied between Management API and Delivery API. protected IActionResult Forbidden() => new StatusCodeResult(StatusCodes.Status403Forbidden); + + protected IActionResult OperationStatusResult(TEnum status, Func result) + where TEnum : Enum + => result(new ProblemDetailsBuilder().WithOperationStatus(status)); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/CreateMediaController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/CreateMediaController.cs index 7ea38ded64..ef7165256c 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Media/CreateMediaController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/CreateMediaController.cs @@ -3,23 +3,18 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Factories; -using Umbraco.Cms.Api.Management.Security.Authorization.Media; using Umbraco.Cms.Api.Management.ViewModels.Media; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; -using Umbraco.Cms.Web.Common.Authorization; -using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Controllers.Media; [ApiVersion("1.0")] -public class CreateMediaController : MediaControllerBase +public class CreateMediaController : CreateMediaControllerBase { - private readonly IAuthorizationService _authorizationService; private readonly IMediaEditingPresentationFactory _mediaEditingPresentationFactory; private readonly IMediaEditingService _mediaEditingService; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; @@ -29,8 +24,8 @@ public class CreateMediaController : MediaControllerBase IMediaEditingPresentationFactory mediaEditingPresentationFactory, IMediaEditingService mediaEditingService, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : base(authorizationService) { - _authorizationService = authorizationService; _mediaEditingPresentationFactory = mediaEditingPresentationFactory; _mediaEditingService = mediaEditingService; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; @@ -39,24 +34,16 @@ public class CreateMediaController : MediaControllerBase [HttpPost] [MapToApiVersion("1.0")] [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] - public async Task Create(CreateMediaRequestModel createRequestModel) - { - AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( - User, - MediaPermissionResource.WithKeys(createRequestModel.Parent?.Id), - AuthorizationPolicies.MediaPermissionByResource); - - if (!authorizationResult.Succeeded) + public async Task Create(CreateMediaRequestModel requestModel) + => await HandleRequest(requestModel.Parent?.Id, async () => { - return Forbidden(); - } + MediaCreateModel model = _mediaEditingPresentationFactory.MapCreateModel(requestModel); + Attempt result = await _mediaEditingService.CreateAsync(model, CurrentUserKey(_backOfficeSecurityAccessor)); - MediaCreateModel model = _mediaEditingPresentationFactory.MapCreateModel(createRequestModel); - Attempt result = await _mediaEditingService.CreateAsync(model, CurrentUserKey(_backOfficeSecurityAccessor)); - - return result.Success - ? CreatedAtId(controller => nameof(controller.ByKey), result.Result!.Key) - : ContentEditingOperationStatusResult(result.Status); - } + return result.Success + ? CreatedAtId(controller => nameof(controller.ByKey), result.Result.Content!.Key) + : ContentEditingOperationStatusResult(result.Status); + }); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/CreateMediaControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/CreateMediaControllerBase.cs new file mode 100644 index 0000000000..0f922cba0f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/CreateMediaControllerBase.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Security.Authorization.Media; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Media; + +public class CreateMediaControllerBase : MediaControllerBase +{ + private readonly IAuthorizationService _authorizationService; + + public CreateMediaControllerBase(IAuthorizationService authorizationService) + => _authorizationService = authorizationService; + + protected async Task HandleRequest(Guid? parentId, Func> authorizedHandler) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + MediaPermissionResource.WithKeys(parentId), + AuthorizationPolicies.MediaPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + return await authorizedHandler(); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/MediaControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/MediaControllerBase.cs index 6e9d0de0a0..48b60283c7 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Media/MediaControllerBase.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/MediaControllerBase.cs @@ -3,7 +3,12 @@ using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Common.Builders; using Umbraco.Cms.Api.Management.Content; using Umbraco.Cms.Api.Management.Routing; +using Umbraco.Cms.Api.Management.ViewModels.Content; +using Umbraco.Cms.Api.Management.ViewModels.Media; using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.ContentEditing.Validation; +using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Web.Common.Authorization; namespace Umbraco.Cms.Api.Management.Controllers.Media; @@ -14,7 +19,15 @@ namespace Umbraco.Cms.Api.Management.Controllers.Media; [Authorize(Policy = "New" + AuthorizationPolicies.SectionAccessMedia)] public class MediaControllerBase : ContentControllerBase { - protected IActionResult MediaNotFound() => NotFound(new ProblemDetailsBuilder() - .WithTitle("The requested Media could not be found") - .Build()); + protected IActionResult MediaNotFound() + => OperationStatusResult(ContentEditingOperationStatus.NotFound, problemDetailsBuilder + => NotFound(problemDetailsBuilder + .WithTitle("The requested Media could not be found") + .Build())); + protected IActionResult MediaEditingOperationStatusResult( + ContentEditingOperationStatus status, + TContentModelBase requestModel, + ContentValidationResult validationResult) + where TContentModelBase : ContentModelBase + => ContentEditingOperationStatusResult(status, requestModel, validationResult); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/UpdateMediaController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/UpdateMediaController.cs index 975039a3f6..e3a5802d6e 100644 --- a/src/Umbraco.Cms.Api.Management/Controllers/Media/UpdateMediaController.cs +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/UpdateMediaController.cs @@ -3,23 +3,18 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Umbraco.Cms.Api.Management.Factories; -using Umbraco.Cms.Api.Management.Security.Authorization.Media; using Umbraco.Cms.Api.Management.ViewModels.Media; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.OperationStatus; -using Umbraco.Cms.Web.Common.Authorization; -using Umbraco.Extensions; namespace Umbraco.Cms.Api.Management.Controllers.Media; [ApiVersion("1.0")] -public class UpdateMediaController : MediaControllerBase +public class UpdateMediaController : UpdateMediaControllerBase { - private readonly IAuthorizationService _authorizationService; private readonly IMediaEditingService _mediaEditingService; private readonly IMediaEditingPresentationFactory _mediaEditingPresentationFactory; private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor; @@ -29,8 +24,8 @@ public class UpdateMediaController : MediaControllerBase IMediaEditingService mediaEditingService, IMediaEditingPresentationFactory mediaEditingPresentationFactory, IBackOfficeSecurityAccessor backOfficeSecurityAccessor) + : base(authorizationService, mediaEditingService) { - _authorizationService = authorizationService; _mediaEditingService = mediaEditingService; _mediaEditingPresentationFactory = mediaEditingPresentationFactory; _backOfficeSecurityAccessor = backOfficeSecurityAccessor; @@ -39,30 +34,17 @@ public class UpdateMediaController : MediaControllerBase [HttpPut("{id:guid}")] [MapToApiVersion("1.0")] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] - public async Task Update(Guid id, UpdateMediaRequestModel updateRequestModel) - { - AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( - User, - MediaPermissionResource.WithKeys(id), - AuthorizationPolicies.MediaPermissionByResource); - - if (!authorizationResult.Succeeded) + public async Task Update(Guid id, UpdateMediaRequestModel requestModel) + => await HandleRequest(id, async media => { - return Forbidden(); - } + MediaUpdateModel model = _mediaEditingPresentationFactory.MapUpdateModel(requestModel); + Attempt result = + await _mediaEditingService.UpdateAsync(media, model, CurrentUserKey(_backOfficeSecurityAccessor)); - IMedia? media = await _mediaEditingService.GetAsync(id); - if (media == null) - { - return MediaNotFound(); - } - - MediaUpdateModel model = _mediaEditingPresentationFactory.MapUpdateModel(updateRequestModel); - Attempt result = await _mediaEditingService.UpdateAsync(media, model, CurrentUserKey(_backOfficeSecurityAccessor)); - - return result.Success - ? Ok() - : ContentEditingOperationStatusResult(result.Status); - } + return result.Success + ? Ok() + : ContentEditingOperationStatusResult(result.Status); + }); } diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/UpdateMediaControllerBase.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/UpdateMediaControllerBase.cs new file mode 100644 index 0000000000..1986a8cc7f --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/UpdateMediaControllerBase.cs @@ -0,0 +1,42 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Security.Authorization.Media; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Api.Management.Controllers.Media; + +public abstract class UpdateMediaControllerBase : MediaControllerBase +{ + private readonly IAuthorizationService _authorizationService; + private readonly IMediaEditingService _mediaEditingService; + + protected UpdateMediaControllerBase(IAuthorizationService authorizationService, IMediaEditingService mediaEditingService) + { + _authorizationService = authorizationService; + _mediaEditingService = mediaEditingService; + } + + protected async Task HandleRequest(Guid id, Func> authorizedHandler) + { + AuthorizationResult authorizationResult = await _authorizationService.AuthorizeResourceAsync( + User, + MediaPermissionResource.WithKeys(id), + AuthorizationPolicies.MediaPermissionByResource); + + if (!authorizationResult.Succeeded) + { + return Forbidden(); + } + + IMedia? media = await _mediaEditingService.GetAsync(id); + if (media is null) + { + return MediaNotFound(); + } + + return await authorizedHandler(media); + } +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/ValidateCreateMediaController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/ValidateCreateMediaController.cs new file mode 100644 index 0000000000..c6d827b734 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/ValidateCreateMediaController.cs @@ -0,0 +1,45 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Media; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Media; + +[ApiVersion("1.0")] +public class ValidateCreateMediaController : CreateMediaControllerBase +{ + private readonly IMediaEditingService _mediaEditingService; + private readonly IMediaEditingPresentationFactory _mediaEditingPresentationFactory; + + public ValidateCreateMediaController( + IAuthorizationService authorizationService, + IMediaEditingService mediaEditingService, + IMediaEditingPresentationFactory mediaEditingPresentationFactory) + : base(authorizationService) + { + _mediaEditingService = mediaEditingService; + _mediaEditingPresentationFactory = mediaEditingPresentationFactory; + } + + [HttpPost("validate")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Validate(CreateMediaRequestModel requestModel) + => await HandleRequest(requestModel.Parent?.Id, async () => + { + MediaCreateModel model = _mediaEditingPresentationFactory.MapCreateModel(requestModel); + Attempt result = await _mediaEditingService.ValidateCreateAsync(model); + + return result.Success + ? Ok() + : MediaEditingOperationStatusResult(result.Status, requestModel, result.Result); + }); +} diff --git a/src/Umbraco.Cms.Api.Management/Controllers/Media/ValidateUpdateMediaController.cs b/src/Umbraco.Cms.Api.Management/Controllers/Media/ValidateUpdateMediaController.cs new file mode 100644 index 0000000000..c77b2eb8b2 --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/Controllers/Media/ValidateUpdateMediaController.cs @@ -0,0 +1,45 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Umbraco.Cms.Api.Management.Factories; +using Umbraco.Cms.Api.Management.ViewModels.Media; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.Controllers.Media; + +[ApiVersion("1.0")] +public class ValidateUpdateMediaController : UpdateMediaControllerBase +{ + private readonly IMediaEditingService _mediaEditingService; + private readonly IMediaEditingPresentationFactory _mediaEditingPresentationFactory; + + public ValidateUpdateMediaController( + IAuthorizationService authorizationService, + IMediaEditingService mediaEditingService, + IMediaEditingPresentationFactory mediaEditingPresentationFactory) + : base(authorizationService, mediaEditingService) + { + _mediaEditingService = mediaEditingService; + _mediaEditingPresentationFactory = mediaEditingPresentationFactory; + } + + [HttpPut("{id:guid}/validate")] + [MapToApiVersion("1.0")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + public async Task Validate(Guid id, UpdateMediaRequestModel requestModel) + => await HandleRequest(id, async content => + { + MediaUpdateModel model = _mediaEditingPresentationFactory.MapUpdateModel(requestModel); + Attempt result = await _mediaEditingService.ValidateUpdateAsync(content, model); + + return result.Success + ? Ok() + : MediaEditingOperationStatusResult(result.Status, requestModel, result.Result); + }); +} diff --git a/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentPublishBranchItemResult.cs b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentPublishBranchItemResult.cs new file mode 100644 index 0000000000..7e880fbbeb --- /dev/null +++ b/src/Umbraco.Cms.Api.Management/ViewModels/Document/DocumentPublishBranchItemResult.cs @@ -0,0 +1,10 @@ +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Api.Management.ViewModels.Document; + +public sealed class DocumentPublishBranchItemResult +{ + public required Guid Id { get; init; } + + public required ContentPublishingOperationStatus OperationStatus { get; init; } +} diff --git a/src/Umbraco.Core/Constants-Validation.cs b/src/Umbraco.Core/Constants-Validation.cs new file mode 100644 index 0000000000..1b081af1bf --- /dev/null +++ b/src/Umbraco.Core/Constants-Validation.cs @@ -0,0 +1,19 @@ +namespace Umbraco.Cms.Core; + +public static partial class Constants +{ + public static class Validation + { + public static class ErrorMessages + { + public static class Properties + { + public const string Missing = "#validation.property.missing"; + + public const string Empty = "#validation.property.empty"; + + public const string PatternMismatch = "#validation.property.pattern"; + } + } + } +} diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 0eee44c4fb..0f8b03a9c9 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -306,11 +306,13 @@ namespace Umbraco.Cms.Core.DependencyInjection Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); + Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); Services.AddUnique(); diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentCreateResult.cs b/src/Umbraco.Core/Models/ContentEditing/ContentCreateResult.cs new file mode 100644 index 0000000000..d90f9611a6 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ContentCreateResult.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class ContentCreateResult : ContentCreateResultBase +{ +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentCreateResultBase.cs b/src/Umbraco.Core/Models/ContentEditing/ContentCreateResultBase.cs new file mode 100644 index 0000000000..a76c142753 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ContentCreateResultBase.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public abstract class ContentCreateResultBase + where TContent : class, IContentBase +{ + public TContent? Content { get; init; } + + public ContentValidationResult ValidationResult { get; init; } = new(); +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentUpdateResult.cs b/src/Umbraco.Core/Models/ContentEditing/ContentUpdateResult.cs new file mode 100644 index 0000000000..a7a3d9ca56 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ContentUpdateResult.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class ContentUpdateResult : ContentUpdateResultBase +{ +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentUpdateResultBase.cs b/src/Umbraco.Core/Models/ContentEditing/ContentUpdateResultBase.cs new file mode 100644 index 0000000000..a16ee66a8d --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ContentUpdateResultBase.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public abstract class ContentUpdateResultBase + where TContent : class, IContentBase +{ + public TContent Content { get; init; } = null!; + + public ContentValidationResult ValidationResult { get; init; } = new(); +} diff --git a/src/Umbraco.Core/Models/ContentEditing/ContentValidationResult.cs b/src/Umbraco.Core/Models/ContentEditing/ContentValidationResult.cs new file mode 100644 index 0000000000..4c693ecc62 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/ContentValidationResult.cs @@ -0,0 +1,8 @@ +using Umbraco.Cms.Core.Models.ContentEditing.Validation; + +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class ContentValidationResult +{ + public IEnumerable ValidationErrors { get; init; } = Enumerable.Empty(); +} diff --git a/src/Umbraco.Core/Models/ContentEditing/MediaCreateResult.cs b/src/Umbraco.Core/Models/ContentEditing/MediaCreateResult.cs new file mode 100644 index 0000000000..13a96ff267 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/MediaCreateResult.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class MediaCreateResult : ContentCreateResultBase +{ +} diff --git a/src/Umbraco.Core/Models/ContentEditing/MediaUpdateResult.cs b/src/Umbraco.Core/Models/ContentEditing/MediaUpdateResult.cs new file mode 100644 index 0000000000..4c71e2d99c --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/MediaUpdateResult.cs @@ -0,0 +1,5 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing; + +public class MediaUpdateResult : ContentUpdateResultBase +{ +} diff --git a/src/Umbraco.Core/Models/ContentEditing/Validation/PropertyValidationError.cs b/src/Umbraco.Core/Models/ContentEditing/Validation/PropertyValidationError.cs new file mode 100644 index 0000000000..74eb3090f5 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentEditing/Validation/PropertyValidationError.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.Models.ContentEditing.Validation; + +public class PropertyValidationError +{ + public required string JsonPath { get; init; } + + public required string[] ErrorMessages { get; init; } + + public required string Alias { get; set; } + + public required string? Culture { get; set; } + + public required string? Segment { get; set; } +} diff --git a/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchItemResult.cs b/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchItemResult.cs new file mode 100644 index 0000000000..03d301452d --- /dev/null +++ b/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchItemResult.cs @@ -0,0 +1,10 @@ +using Umbraco.Cms.Core.Services.OperationStatus; + +namespace Umbraco.Cms.Core.Models.ContentPublishing; + +public sealed class ContentPublishingBranchItemResult +{ + public required Guid Key { get; init; } + + public required ContentPublishingOperationStatus OperationStatus { get; init; } +} diff --git a/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchResult.cs b/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchResult.cs new file mode 100644 index 0000000000..7e09a96225 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingBranchResult.cs @@ -0,0 +1,10 @@ +namespace Umbraco.Cms.Core.Models.ContentPublishing; + +public sealed class ContentPublishingBranchResult +{ + public IContent? Content { get; init; } + + public IEnumerable SucceededItems { get; set; } = []; + + public IEnumerable FailedItems { get; set; } = []; +} diff --git a/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingResult.cs b/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingResult.cs new file mode 100644 index 0000000000..a9307f1972 --- /dev/null +++ b/src/Umbraco.Core/Models/ContentPublishing/ContentPublishingResult.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.Models.ContentPublishing; + +public sealed class ContentPublishingResult +{ + public IContent? Content { get; init; } + + public IEnumerable InvalidPropertyAliases { get; set; } = []; +} diff --git a/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs b/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs index 5f8b33c257..d2c29a938a 100644 --- a/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/DataValueEditor.cs @@ -21,18 +21,33 @@ namespace Umbraco.Cms.Core.PropertyEditors; public class DataValueEditor : IDataValueEditor { private readonly IJsonSerializer? _jsonSerializer; - private readonly ILocalizedTextService _localizedTextService; private readonly IShortStringHelper _shortStringHelper; - /// - /// Initializes a new instance of the class. - /// + [Obsolete($"Use the constructor that does not accept {nameof(ILocalizedTextService)}. Will be removed in V15.")] public DataValueEditor( ILocalizedTextService localizedTextService, IShortStringHelper shortStringHelper, IJsonSerializer? jsonSerializer) // for tests, and manifest + : this(shortStringHelper, jsonSerializer) + { + } + + [Obsolete($"Use the constructor that does not accept {nameof(ILocalizedTextService)}. Will be removed in V15.")] + public DataValueEditor( + ILocalizedTextService localizedTextService, + IShortStringHelper shortStringHelper, + IJsonSerializer jsonSerializer, + IIOHelper ioHelper, + DataEditorAttribute attribute) + : this(shortStringHelper, jsonSerializer, ioHelper, attribute) + { + } + + /// + /// Initializes a new instance of the class. + /// + public DataValueEditor(IShortStringHelper shortStringHelper, IJsonSerializer? jsonSerializer) // for tests, and manifest { - _localizedTextService = localizedTextService; _shortStringHelper = shortStringHelper; _jsonSerializer = jsonSerializer; ValueType = ValueTypes.String; @@ -43,7 +58,6 @@ public class DataValueEditor : IDataValueEditor /// Initializes a new instance of the class. /// public DataValueEditor( - ILocalizedTextService localizedTextService, IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, IIOHelper ioHelper, @@ -54,7 +68,6 @@ public class DataValueEditor : IDataValueEditor throw new ArgumentNullException(nameof(attribute)); } - _localizedTextService = localizedTextService; _shortStringHelper = shortStringHelper; _jsonSerializer = jsonSerializer; @@ -85,12 +98,12 @@ public class DataValueEditor : IDataValueEditor /// /// Gets the validator used to validate the special property type -level "required". /// - public virtual IValueRequiredValidator RequiredValidator => new RequiredValidator(_localizedTextService); + public virtual IValueRequiredValidator RequiredValidator => new RequiredValidator(); /// /// Gets the validator used to validate the special property type -level "format". /// - public virtual IValueFormatValidator FormatValidator => new RegexValidator(_localizedTextService); + public virtual IValueFormatValidator FormatValidator => new RegexValidator(); /// /// Gets or sets the editor view. diff --git a/src/Umbraco.Core/PropertyEditors/TagConfigurationEditor.cs b/src/Umbraco.Core/PropertyEditors/TagConfigurationEditor.cs index 4601f236e2..935c332abf 100644 --- a/src/Umbraco.Core/PropertyEditors/TagConfigurationEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/TagConfigurationEditor.cs @@ -1,8 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using Microsoft.Extensions.DependencyInjection; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.PropertyEditors.Validators; @@ -15,18 +13,17 @@ namespace Umbraco.Cms.Core.PropertyEditors; /// public class TagConfigurationEditor : ConfigurationEditor { - // Scheduled for removal in v12 - [Obsolete("Please use constructor that takes an IEditorConfigurationParser instead")] - public TagConfigurationEditor(ManifestValueValidatorCollection validators, IIOHelper ioHelper, ILocalizedTextService localizedTextService) - : this(validators, ioHelper, localizedTextService, StaticServiceProvider.Instance.GetRequiredService()) + [Obsolete($"Use the constructor that does not accept {nameof(ILocalizedTextService)}. Will be removed in V15.")] + public TagConfigurationEditor(ManifestValueValidatorCollection validators, IIOHelper ioHelper, ILocalizedTextService localizedTextService, IEditorConfigurationParser editorConfigurationParser) + : this(ioHelper, editorConfigurationParser) { } - public TagConfigurationEditor(ManifestValueValidatorCollection validators, IIOHelper ioHelper, ILocalizedTextService localizedTextService, IEditorConfigurationParser editorConfigurationParser) + public TagConfigurationEditor(IIOHelper ioHelper, IEditorConfigurationParser editorConfigurationParser) : base(ioHelper, editorConfigurationParser) { - Field(nameof(TagConfiguration.Group)).Validators.Add(new RequiredValidator(localizedTextService)); - Field(nameof(TagConfiguration.StorageType)).Validators.Add(new RequiredValidator(localizedTextService)); + Field(nameof(TagConfiguration.Group)).Validators.Add(new RequiredValidator()); + Field(nameof(TagConfiguration.StorageType)).Validators.Add(new RequiredValidator()); } public override IDictionary ToConfigurationEditor(IDictionary configuration) diff --git a/src/Umbraco.Core/PropertyEditors/TextOnlyValueEditor.cs b/src/Umbraco.Core/PropertyEditors/TextOnlyValueEditor.cs index 6a0995dccd..0b0d34b1ef 100644 --- a/src/Umbraco.Core/PropertyEditors/TextOnlyValueEditor.cs +++ b/src/Umbraco.Core/PropertyEditors/TextOnlyValueEditor.cs @@ -12,13 +12,23 @@ namespace Umbraco.Cms.Core.PropertyEditors; /// public class TextOnlyValueEditor : DataValueEditor { + [Obsolete($"Use the constructor that does not accept {nameof(ILocalizedTextService)}. Will be removed in V15.")] public TextOnlyValueEditor( DataEditorAttribute attribute, ILocalizedTextService localizedTextService, IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, IIOHelper ioHelper) - : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) + : this(attribute, shortStringHelper, jsonSerializer, ioHelper) + { + } + + public TextOnlyValueEditor( + DataEditorAttribute attribute, + IShortStringHelper shortStringHelper, + IJsonSerializer jsonSerializer, + IIOHelper ioHelper) + : base(shortStringHelper, jsonSerializer, ioHelper, attribute) { } diff --git a/src/Umbraco.Core/PropertyEditors/Validation/IJsonPathValidationResult.cs b/src/Umbraco.Core/PropertyEditors/Validation/IJsonPathValidationResult.cs new file mode 100644 index 0000000000..2b3c2b3525 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/Validation/IJsonPathValidationResult.cs @@ -0,0 +1,6 @@ +namespace Umbraco.Cms.Core.PropertyEditors.Validation; + +public interface IJsonPathValidationResult +{ + string JsonPath { get; } +} diff --git a/src/Umbraco.Core/PropertyEditors/Validation/INestedValidationResults.cs b/src/Umbraco.Core/PropertyEditors/Validation/INestedValidationResults.cs new file mode 100644 index 0000000000..953119a85a --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/Validation/INestedValidationResults.cs @@ -0,0 +1,8 @@ +using System.ComponentModel.DataAnnotations; + +namespace Umbraco.Cms.Core.PropertyEditors.Validation; + +public interface INestedValidationResults +{ + IList ValidationResults { get; } +} diff --git a/src/Umbraco.Core/PropertyEditors/Validation/JsonPathValidationError.cs b/src/Umbraco.Core/PropertyEditors/Validation/JsonPathValidationError.cs new file mode 100644 index 0000000000..36e06633b6 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/Validation/JsonPathValidationError.cs @@ -0,0 +1,8 @@ +namespace Umbraco.Cms.Core.PropertyEditors.Validation; + +internal class JsonPathValidationError +{ + public required string JsonPath { get; init; } + + public required IEnumerable ErrorMessages { get; init; } +} diff --git a/src/Umbraco.Core/PropertyEditors/Validation/JsonPathValidator.cs b/src/Umbraco.Core/PropertyEditors/Validation/JsonPathValidator.cs new file mode 100644 index 0000000000..307abe22a5 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/Validation/JsonPathValidator.cs @@ -0,0 +1,66 @@ +using System.ComponentModel.DataAnnotations; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.PropertyEditors.Validation; + +internal static class JsonPathValidator +{ + public static IEnumerable ExtractJsonPathValidationErrors(ValidationResult validationResult) + { + var root = new JsonPathValidationTreeItem { JsonPath = string.Empty }; + BuildJsonPathValidationTreeRecursively(validationResult, root); + return ExtractJsonPathValidationErrorsRecursively(root); + } + + private static void BuildJsonPathValidationTreeRecursively(ValidationResult validationResult, JsonPathValidationTreeItem current) + { + if (validationResult is not INestedValidationResults nestedValidationResults || nestedValidationResults.ValidationResults.Any() is false) + { + return; + } + + if (validationResult is IJsonPathValidationResult jsonPathValidationResult) + { + current.Children.Add(new JsonPathValidationTreeItem + { + JsonPath = $"{current.JsonPath}.{jsonPathValidationResult.JsonPath}", + }); + current = current.Children.Last(); + } + + current.ErrorMessages.AddRange( + nestedValidationResults.ValidationResults + .Select(child => child.ErrorMessage?.NullOrWhiteSpaceAsNull()) + .WhereNotNull()); + + foreach (ValidationResult child in nestedValidationResults.ValidationResults) + { + BuildJsonPathValidationTreeRecursively(child, current); + } + } + + private static IEnumerable ExtractJsonPathValidationErrorsRecursively(JsonPathValidationTreeItem current) + { + var errors = new List(); + if (current.ErrorMessages.Any()) + { + errors.Add(new JsonPathValidationError { ErrorMessages = current.ErrorMessages, JsonPath = current.JsonPath }); + } + + foreach (JsonPathValidationTreeItem child in current.Children) + { + errors.AddRange(ExtractJsonPathValidationErrorsRecursively(child)); + } + + return errors; + } + + private class JsonPathValidationTreeItem + { + public required string JsonPath { get; init; } + + public List ErrorMessages { get; } = []; + + public List Children { get; } = []; + } +} diff --git a/src/Umbraco.Core/PropertyEditors/Validation/NestedJsonPathValidationResults.cs b/src/Umbraco.Core/PropertyEditors/Validation/NestedJsonPathValidationResults.cs new file mode 100644 index 0000000000..52358f8e64 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/Validation/NestedJsonPathValidationResults.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Umbraco.Cms.Core.PropertyEditors.Validation; + +public class NestedJsonPathValidationResults : ValidationResult, INestedValidationResults, IJsonPathValidationResult +{ + public string JsonPath { get; } + + public NestedJsonPathValidationResults(string jsonPath) + : base(string.Empty) + => JsonPath = jsonPath; + + public IList ValidationResults { get; } = []; +} diff --git a/src/Umbraco.Core/PropertyEditors/Validation/NestedValidationResults.cs b/src/Umbraco.Core/PropertyEditors/Validation/NestedValidationResults.cs new file mode 100644 index 0000000000..859439ff93 --- /dev/null +++ b/src/Umbraco.Core/PropertyEditors/Validation/NestedValidationResults.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Umbraco.Cms.Core.PropertyEditors.Validation; + +public class NestedValidationResults : ValidationResult, INestedValidationResults +{ + public NestedValidationResults() + : base(string.Empty) + { + } + + public IList ValidationResults { get; } = []; +} diff --git a/src/Umbraco.Core/PropertyEditors/Validators/RegexValidator.cs b/src/Umbraco.Core/PropertyEditors/Validators/RegexValidator.cs index 5a9032303c..5c7a172ec8 100644 --- a/src/Umbraco.Core/PropertyEditors/Validators/RegexValidator.cs +++ b/src/Umbraco.Core/PropertyEditors/Validators/RegexValidator.cs @@ -4,7 +4,6 @@ using System.ComponentModel.DataAnnotations; using System.Text.RegularExpressions; using Umbraco.Cms.Core.Services; -using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors.Validators; @@ -13,10 +12,20 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validators; /// public sealed class RegexValidator : IValueFormatValidator, IManifestValueValidator { - private const string ValueIsInvalid = "Value is invalid, it does not match the correct pattern"; - private readonly ILocalizedTextService _textService; private string _regex; + [Obsolete($"Use the constructor that does not accept {nameof(ILocalizedTextService)}. Will be removed in V15.")] + public RegexValidator(ILocalizedTextService textService) + : this(string.Empty) + { + } + + [Obsolete($"Use the constructor that does not accept {nameof(ILocalizedTextService)}. Will be removed in V15.")] + public RegexValidator(ILocalizedTextService textService, string regex) + : this(regex) + { + } + /// /// Initializes a new instance of the class. /// @@ -26,8 +35,8 @@ public sealed class RegexValidator : IValueFormatValidator, IManifestValueValida /// the validator is used as an and the regular expression /// is supplied via the method. /// - public RegexValidator(ILocalizedTextService textService) - : this(textService, string.Empty) + public RegexValidator() + : this(string.Empty) { } @@ -38,11 +47,8 @@ public sealed class RegexValidator : IValueFormatValidator, IManifestValueValida /// Use this constructor when the validator is used as an , /// and the regular expression must be supplied when the validator is created. /// - public RegexValidator(ILocalizedTextService textService, string regex) - { - _textService = textService; - _regex = regex; - } + public RegexValidator(string regex) + => _regex = regex; /// /// Gets or sets the configuration, when parsed as . @@ -99,9 +105,7 @@ public sealed class RegexValidator : IValueFormatValidator, IManifestValueValida if (value == null || !new Regex(format).IsMatch(value.ToString()!)) { - yield return new ValidationResult( - _textService?.Localize("validation", "invalidPattern") ?? ValueIsInvalid, - new[] { "value" }); + yield return new ValidationResult(Constants.Validation.ErrorMessages.Properties.PatternMismatch, new[] { "value" }); } } } diff --git a/src/Umbraco.Core/PropertyEditors/Validators/RequiredValidator.cs b/src/Umbraco.Core/PropertyEditors/Validators/RequiredValidator.cs index 296e8eed36..33a7c45afe 100644 --- a/src/Umbraco.Core/PropertyEditors/Validators/RequiredValidator.cs +++ b/src/Umbraco.Core/PropertyEditors/Validators/RequiredValidator.cs @@ -9,11 +9,15 @@ namespace Umbraco.Cms.Core.PropertyEditors.Validators; /// public sealed class RequiredValidator : IValueRequiredValidator, IManifestValueValidator { - private const string ValueCannotBeNull = "Value cannot be null"; - private const string ValueCannotBeEmpty = "Value cannot be empty"; - private readonly ILocalizedTextService _textService; + [Obsolete($"Use the constructor that does not accept {nameof(ILocalizedTextService)}. Will be removed in V15.")] + public RequiredValidator(ILocalizedTextService textService) + : this() + { + } - public RequiredValidator(ILocalizedTextService textService) => _textService = textService; + public RequiredValidator() + { + } /// public string ValidationName => "Required"; @@ -27,9 +31,7 @@ public sealed class RequiredValidator : IValueRequiredValidator, IManifestValueV { if (value == null) { - yield return new ValidationResult( - _textService?.Localize("validation", "invalidNull") ?? ValueCannotBeNull, - new[] { "value" }); + yield return new ValidationResult(Constants.Validation.ErrorMessages.Properties.Missing, new[] { "value" }); yield break; } @@ -37,8 +39,7 @@ public sealed class RequiredValidator : IValueRequiredValidator, IManifestValueV { if (value.ToString()?.DetectIsEmptyJson() ?? false) { - yield return new ValidationResult( - _textService?.Localize("validation", "invalidEmpty") ?? ValueCannotBeEmpty, new[] { "value" }); + yield return new ValidationResult(Constants.Validation.ErrorMessages.Properties.Empty, new[] { "value" }); } yield break; @@ -46,8 +47,7 @@ public sealed class RequiredValidator : IValueRequiredValidator, IManifestValueV if (value.ToString().IsNullOrWhiteSpace()) { - yield return new ValidationResult( - _textService?.Localize("validation", "invalidEmpty") ?? ValueCannotBeEmpty, new[] { "value" }); + yield return new ValidationResult(Constants.Validation.ErrorMessages.Properties.Empty, new[] { "value" }); } } } diff --git a/src/Umbraco.Core/Services/ContentEditingService.cs b/src/Umbraco.Core/Services/ContentEditingService.cs index ae5ee0c594..c2f86c5f61 100644 --- a/src/Umbraco.Core/Services/ContentEditingService.cs +++ b/src/Umbraco.Core/Services/ContentEditingService.cs @@ -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 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> CreateAsync(ContentCreateModel createModel, Guid userKey) + public async Task> ValidateUpdateAsync(IContent content, ContentUpdateModel updateModel) + => await ValidatePropertiesAsync(updateModel, content.ContentType.Key); + + public async Task> ValidateCreateAsync(ContentCreateModel createModel) + => await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey); + + public async Task> CreateAsync(ContentCreateModel createModel, Guid userKey) { - Attempt result = await MapCreate(createModel); + Attempt result = await MapCreate(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(operationStatus, content); + return Attempt.FailWithStatus(updateTemplateStatus, new ContentCreateResult { 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 ContentCreateResult { Content = content, ValidationResult = validationResult }) + : Attempt.FailWithStatus(saveStatus, new ContentCreateResult { Content = content }); } - public async Task> UpdateAsync(IContent content, ContentUpdateModel updateModel, Guid userKey) + public async Task> UpdateAsync(IContent content, ContentUpdateModel updateModel, Guid userKey) { - Attempt result = await MapUpdate(content, updateModel); + Attempt result = await MapUpdate(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> MoveToRecycleBinAsync(Guid key, Guid userKey) diff --git a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs index c7c589b922..88222086cf 100644 --- a/src/Umbraco.Core/Services/ContentEditingServiceBase.cs +++ b/src/Umbraco.Core/Services/ContentEditingServiceBase.cs @@ -20,6 +20,7 @@ internal abstract class ContentEditingServiceBase> _logger; private readonly ITreeEntitySortingService _treeEntitySortingService; private readonly IUserIdKeyResolver _userIdKeyResolver; + private readonly IContentValidationServiceBase _validationService; protected ContentEditingServiceBase( TContentService contentService, @@ -29,13 +30,15 @@ internal abstract class ContentEditingServiceBase> logger, ICoreScopeProvider scopeProvider, IUserIdKeyResolver userIdKeyResolver, - ITreeEntitySortingService treeEntitySortingService) + ITreeEntitySortingService treeEntitySortingService, + IContentValidationServiceBase 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> MapCreate(ContentCreationModelBase contentCreationModelBase) + protected async Task> MapCreate(ContentCreationModelBase contentCreationModelBase) + where TContentCreateResult : ContentCreateResultBase, new() { TContentType? contentType = TryGetAndValidateContentType(contentCreationModelBase.ContentTypeKey, contentCreationModelBase, out ContentEditingOperationStatus operationStatus); if (contentType == null) { - return Attempt.FailWithStatus(operationStatus, null); + return Attempt.FailWithStatus(operationStatus, new TContentCreateResult()); } TContent? parent = TryGetAndValidateParent(contentCreationModelBase.ParentKey, contentType, out operationStatus); if (operationStatus != ContentEditingOperationStatus.Success) { - return Attempt.FailWithStatus(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 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(ContentEditingOperationStatus.Success, content); + return Attempt.SucceedWithStatus(validationResult.Status, new TContentCreateResult { Content = content, ValidationResult = validationResult.Result }); } - protected async Task> MapUpdate(TContent content, ContentEditingModelBase contentEditingModelBase) + protected async Task> MapUpdate(TContent content, ContentEditingModelBase contentEditingModelBase) + where TContentUpdateResult : ContentUpdateResultBase, 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 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> 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> 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> HandleMoveToRecycleBinAsync(Guid key, Guid userKey) diff --git a/src/Umbraco.Core/Services/ContentPublishingService.cs b/src/Umbraco.Core/Services/ContentPublishingService.cs index a3f8da6748..981ea9f5de 100644 --- a/src/Umbraco.Core/Services/ContentPublishingService.cs +++ b/src/Umbraco.Core/Services/ContentPublishingService.cs @@ -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 } /// - public async Task> PublishAsync(Guid key, IEnumerable cultures, Guid userKey) + public async Task> PublishAsync(Guid key, IEnumerable 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() + }); } /// - public async Task>> PublishBranchAsync(Guid key, IEnumerable cultures, bool force, Guid userKey) + public async Task> PublishBranchAsync(Guid key, IEnumerable cultures, bool force, Guid userKey) { using ICoreScope scope = _coreScopeProvider.CreateCoreScope(); IContent? content = _contentService.GetById(key); if (content is null) { - var payload = new Dictionary - { - { key, ContentPublishingOperationStatus.ContentNotFound }, - }; - - return Attempt>.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 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>.Succeed(payloads) - : Attempt>.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); } /// diff --git a/src/Umbraco.Core/Services/ContentValidationService.cs b/src/Umbraco.Core/Services/ContentValidationService.cs new file mode 100644 index 0000000000..093c9eaff3 --- /dev/null +++ b/src/Umbraco.Core/Services/ContentValidationService.cs @@ -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, IContentValidationService +{ + public ContentValidationService(IPropertyValidationService propertyValidationService, ILanguageService languageService) + : base(propertyValidationService, languageService) + { + } + + public async Task ValidatePropertiesAsync( + ContentEditingModelBase contentEditingModelBase, + IContentType contentType) + => await HandlePropertiesValidationAsync(contentEditingModelBase, contentType); +} diff --git a/src/Umbraco.Core/Services/ContentValidationServiceBase.cs b/src/Umbraco.Core/Services/ContentValidationServiceBase.cs new file mode 100644 index 0000000000..416bbf2c24 --- /dev/null +++ b/src/Umbraco.Core/Services/ContentValidationServiceBase.cs @@ -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 + where TContentType : IContentTypeComposition +{ + private readonly ILanguageService _languageService; + private readonly IPropertyValidationService _propertyValidationService; + + protected ContentValidationServiceBase( + IPropertyValidationService propertyValidationService, + ILanguageService languageService) + { + _propertyValidationService = propertyValidationService; + _languageService = languageService; + } + + protected async Task HandlePropertiesValidationAsync( + ContentEditingModelBase contentEditingModelBase, + TContentType contentType) + { + var validationErrors = new List(); + + 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 ValidateProperty(ContentEditingModelBase contentEditingModelBase, IPropertyType propertyType, string? culture, string? segment) + { + IEnumerable? 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[] 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 ExtractPropertyValidationResultJsonPath(ValidationResult validationResult, string alias, string? culture, string? segment) + { + if (validationResult is not INestedValidationResults nestedValidationResults) + { + return Enumerable.Empty(); + } + + 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(); + } +} diff --git a/src/Umbraco.Core/Services/IContentEditingService.cs b/src/Umbraco.Core/Services/IContentEditingService.cs index b00f768ee5..a7e5c12f75 100644 --- a/src/Umbraco.Core/Services/IContentEditingService.cs +++ b/src/Umbraco.Core/Services/IContentEditingService.cs @@ -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 GetAsync(Guid key); - Task> CreateAsync(ContentCreateModel createModel, Guid userKey); + Task> ValidateCreateAsync(ContentCreateModel createModel); - Task> UpdateAsync(IContent content, ContentUpdateModel updateModel, Guid userKey); + Task> ValidateUpdateAsync(IContent content, ContentUpdateModel updateModel); + + Task> CreateAsync(ContentCreateModel createModel, Guid userKey); + + Task> UpdateAsync(IContent content, ContentUpdateModel updateModel, Guid userKey); Task> MoveToRecycleBinAsync(Guid key, Guid userKey); diff --git a/src/Umbraco.Core/Services/IContentPublishingService.cs b/src/Umbraco.Core/Services/IContentPublishingService.cs index 6218a38bc8..eb688655a7 100644 --- a/src/Umbraco.Core/Services/IContentPublishingService.cs +++ b/src/Umbraco.Core/Services/IContentPublishingService.cs @@ -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 /// The key of the root content. /// The cultures to publish. /// The identifier of the user performing the operation. - /// Status of the publish operation. - Task> PublishAsync(Guid key, IEnumerable cultures, Guid userKey); + /// Result of the publish operation. + Task> PublishAsync(Guid key, IEnumerable cultures, Guid userKey); /// /// Publishes a content branch. @@ -20,8 +21,8 @@ public interface IContentPublishingService /// The cultures to publish. /// A value indicating whether to force-publish content that is not already published. /// The identifier of the user performing the operation. - /// A dictionary of attempted content item keys and their corresponding publishing status. - Task>> PublishBranchAsync(Guid key, IEnumerable cultures, bool force, Guid userKey); + /// Result of the publish operation. + Task> PublishBranchAsync(Guid key, IEnumerable cultures, bool force, Guid userKey); /// /// Unpublishes a single content item. diff --git a/src/Umbraco.Core/Services/IContentValidationService.cs b/src/Umbraco.Core/Services/IContentValidationService.cs new file mode 100644 index 0000000000..e06322fcd7 --- /dev/null +++ b/src/Umbraco.Core/Services/IContentValidationService.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +internal interface IContentValidationService : IContentValidationServiceBase +{ +} diff --git a/src/Umbraco.Core/Services/IContentValidationServiceBase.cs b/src/Umbraco.Core/Services/IContentValidationServiceBase.cs new file mode 100644 index 0000000000..0ce4f77b16 --- /dev/null +++ b/src/Umbraco.Core/Services/IContentValidationServiceBase.cs @@ -0,0 +1,10 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; + +namespace Umbraco.Cms.Core.Services; + +internal interface IContentValidationServiceBase + where TContentType : IContentTypeComposition +{ + Task ValidatePropertiesAsync(ContentEditingModelBase contentEditingModelBase, TContentType contentType); +} diff --git a/src/Umbraco.Core/Services/IMediaEditingService.cs b/src/Umbraco.Core/Services/IMediaEditingService.cs index 707939fcc2..45761e575f 100644 --- a/src/Umbraco.Core/Services/IMediaEditingService.cs +++ b/src/Umbraco.Core/Services/IMediaEditingService.cs @@ -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 GetAsync(Guid key); - Task> CreateAsync(MediaCreateModel createModel, Guid userKey); + Task> ValidateCreateAsync(MediaCreateModel createModel); - Task> UpdateAsync(IMedia media, MediaUpdateModel updateModel, Guid userKey); + Task> ValidateUpdateAsync(IMedia media, MediaUpdateModel updateModel); + + Task> CreateAsync(MediaCreateModel createModel, Guid userKey); + + Task> UpdateAsync(IMedia media, MediaUpdateModel updateModel, Guid userKey); Task> MoveToRecycleBinAsync(Guid key, Guid userKey); diff --git a/src/Umbraco.Core/Services/IMediaValidationService.cs b/src/Umbraco.Core/Services/IMediaValidationService.cs new file mode 100644 index 0000000000..12365ef947 --- /dev/null +++ b/src/Umbraco.Core/Services/IMediaValidationService.cs @@ -0,0 +1,7 @@ +using Umbraco.Cms.Core.Models; + +namespace Umbraco.Cms.Core.Services; + +internal interface IMediaValidationService : IContentValidationServiceBase +{ +} diff --git a/src/Umbraco.Core/Services/MediaEditingService.cs b/src/Umbraco.Core/Services/MediaEditingService.cs index 570e595be8..a8b1b54a83 100644 --- a/src/Umbraco.Core/Services/MediaEditingService.cs +++ b/src/Umbraco.Core/Services/MediaEditingService.cs @@ -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> 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 GetAsync(Guid key) @@ -31,36 +33,52 @@ internal sealed class MediaEditingService return await Task.FromResult(media); } - public async Task> CreateAsync(MediaCreateModel createModel, Guid userKey) + public async Task> ValidateUpdateAsync(IMedia media, MediaUpdateModel updateModel) + => await ValidatePropertiesAsync(updateModel, media.ContentType.Key); + + public async Task> ValidateCreateAsync(MediaCreateModel createModel) + => await ValidatePropertiesAsync(createModel, createModel.ContentTypeKey); + + public async Task> CreateAsync(MediaCreateModel createModel, Guid userKey) { - Attempt result = await MapCreate(createModel); + Attempt result = await MapCreate(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(ContentEditingOperationStatus.Success, media) - : Attempt.FailWithStatus(operationStatus, media); + ? Attempt.SucceedWithStatus(validationStatus, new MediaCreateResult { Content = media, ValidationResult = validationResult }) + : Attempt.FailWithStatus(operationStatus, new MediaCreateResult { Content = media }); } - public async Task> UpdateAsync(IMedia media, MediaUpdateModel updateModel, Guid userKey) + public async Task> UpdateAsync(IMedia media, MediaUpdateModel updateModel, Guid userKey) { - Attempt result = await MapUpdate(media, updateModel); + Attempt result = await MapUpdate(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> MoveToRecycleBinAsync(Guid key, Guid userKey) diff --git a/src/Umbraco.Core/Services/MediaValidationService.cs b/src/Umbraco.Core/Services/MediaValidationService.cs new file mode 100644 index 0000000000..872dce7b28 --- /dev/null +++ b/src/Umbraco.Core/Services/MediaValidationService.cs @@ -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, IMediaValidationService +{ + public MediaValidationService(IPropertyValidationService propertyValidationService, ILanguageService languageService) + : base(propertyValidationService, languageService) + { + } + + public async Task ValidatePropertiesAsync( + ContentEditingModelBase contentEditingModelBase, + IMediaType mediaType) + => await HandlePropertiesValidationAsync(contentEditingModelBase, mediaType); +} diff --git a/src/Umbraco.Core/Services/OperationStatus/ContentEditingOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/ContentEditingOperationStatus.cs index 3fbe349f1d..76ae8a21e5 100644 --- a/src/Umbraco.Core/Services/OperationStatus/ContentEditingOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/ContentEditingOperationStatus.cs @@ -16,5 +16,6 @@ public enum ContentEditingOperationStatus InTrash, NotInTrash, SortingInvalid, + PropertyValidationError, Unknown } diff --git a/src/Umbraco.Core/Services/OperationStatus/ContentPublishingOperationStatus.cs b/src/Umbraco.Core/Services/OperationStatus/ContentPublishingOperationStatus.cs index f72d04f018..c98ea2eeed 100644 --- a/src/Umbraco.Core/Services/OperationStatus/ContentPublishingOperationStatus.cs +++ b/src/Umbraco.Core/Services/OperationStatus/ContentPublishingOperationStatus.cs @@ -16,6 +16,7 @@ public enum ContentPublishingOperationStatus PathNotPublished, ConcurrencyViolation, UnsavedChanges, + FailedBranch, Failed, // unspecified failure (can happen on unpublish at the time of writing) Unknown } diff --git a/src/Umbraco.Core/Services/PropertyValidationService.cs b/src/Umbraco.Core/Services/PropertyValidationService.cs index b9fc4afd30..74faacabd0 100644 --- a/src/Umbraco.Core/Services/PropertyValidationService.cs +++ b/src/Umbraco.Core/Services/PropertyValidationService.cs @@ -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)) diff --git a/src/Umbraco.Infrastructure/Manifest/DataEditorConverter.cs b/src/Umbraco.Infrastructure/Manifest/DataEditorConverter.cs index 6b79e718e7..c5cd5596ec 100644 --- a/src/Umbraco.Infrastructure/Manifest/DataEditorConverter.cs +++ b/src/Umbraco.Infrastructure/Manifest/DataEditorConverter.cs @@ -19,22 +19,30 @@ internal class DataEditorConverter : JsonReadConverter private readonly IIOHelper _ioHelper; private readonly IJsonSerializer _jsonSerializer; private readonly IShortStringHelper _shortStringHelper; - private readonly ILocalizedTextService _textService; private const string SupportsReadOnly = "supportsReadOnly"; + [Obsolete($"Use the constructor that does not accept {nameof(ILocalizedTextService)}. Will be removed in V15.")] + public DataEditorConverter( + IDataValueEditorFactory dataValueEditorFactory, + IIOHelper ioHelper, + ILocalizedTextService textService, + IShortStringHelper shortStringHelper, + IJsonSerializer jsonSerializer) + : this(dataValueEditorFactory, ioHelper, shortStringHelper, jsonSerializer) + { + } + /// /// Initializes a new instance of the class. /// public DataEditorConverter( IDataValueEditorFactory dataValueEditorFactory, IIOHelper ioHelper, - ILocalizedTextService textService, IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer) { _dataValueEditorFactory = dataValueEditorFactory; _ioHelper = ioHelper; - _textService = textService; _shortStringHelper = shortStringHelper; _jsonSerializer = jsonSerializer; } @@ -119,7 +127,7 @@ internal class DataEditorConverter : JsonReadConverter // explicitly assign a value editor of type ValueEditor // (else the deserializer will try to read it before setting it) // (and besides it's an interface) - target.ExplicitValueEditor = new DataValueEditor(_textService, _shortStringHelper, _jsonSerializer); + target.ExplicitValueEditor = new DataValueEditor(_shortStringHelper, _jsonSerializer); // in the manifest, validators are a simple dictionary eg // { @@ -203,7 +211,7 @@ internal class DataEditorConverter : JsonReadConverter if (jobject.Property("view") != null) { // explicitly assign a value editor of type ParameterValueEditor - target.ExplicitValueEditor = new DataValueEditor(_textService, _shortStringHelper, _jsonSerializer); + target.ExplicitValueEditor = new DataValueEditor(_shortStringHelper, _jsonSerializer); // move the 'view' property jobject["editor"] = new JObject { ["view"] = jobject["view"] }; diff --git a/src/Umbraco.Infrastructure/Manifest/LegacyManifestParser.cs b/src/Umbraco.Infrastructure/Manifest/LegacyManifestParser.cs index a0a397b1f7..e4ce537454 100644 --- a/src/Umbraco.Infrastructure/Manifest/LegacyManifestParser.cs +++ b/src/Umbraco.Infrastructure/Manifest/LegacyManifestParser.cs @@ -175,7 +175,7 @@ public class LegacyManifestParser : ILegacyManifestParser LegacyPackageManifest? manifest = JsonConvert.DeserializeObject( text, - new DataEditorConverter(_dataValueEditorFactory, _ioHelper, _localizedTextService, _shortStringHelper, _jsonSerializer), + new DataEditorConverter(_dataValueEditorFactory, _ioHelper, _shortStringHelper, _jsonSerializer), new ValueValidatorConverter(_validators), new DashboardAccessRuleConverter())!; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValidatorBase.cs b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValidatorBase.cs index ecddb784cf..43ce39a743 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValidatorBase.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/BlockEditorValidatorBase.cs @@ -1,6 +1,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.Blocks; using Umbraco.Cms.Core.Services; +using Umbraco.Extensions; namespace Umbraco.Cms.Core.PropertyEditors; @@ -19,35 +20,45 @@ internal abstract class BlockEditorValidatorBase : ComplexEdito // There is no guarantee that the client will post data for every property defined in the Element Type but we still // need to validate that data for each property especially for things like 'required' data to work. // Lookup all element types for all content/settings and then we can populate any empty properties. - var allElements = blockEditorData.BlockValue.ContentData.Concat(blockEditorData.BlockValue.SettingsData).ToList(); - var allElementTypes = _contentTypeService.GetAll(allElements.Select(x => x.ContentTypeKey).ToArray()).ToDictionary(x => x.Key); - foreach (BlockItemData row in allElements) + var itemDataGroups = new[] { - if (!allElementTypes.TryGetValue(row.ContentTypeKey, out IContentType? elementType)) - { - throw new InvalidOperationException($"No element type found with key {row.ContentTypeKey}"); - } + new { Path = nameof(BlockValue.ContentData).ToFirstLowerInvariant(), Items = blockEditorData.BlockValue.ContentData }, + new { Path = nameof(BlockValue.SettingsData).ToFirstLowerInvariant(), Items = blockEditorData.BlockValue.SettingsData } + }; - // now ensure missing properties - foreach (IPropertyType elementTypeProp in elementType.CompositionPropertyTypes) + foreach (var group in itemDataGroups) + { + var allElementTypes = _contentTypeService.GetAll(group.Items.Select(x => x.ContentTypeKey).ToArray()).ToDictionary(x => x.Key); + + for (var i = 0; i < group.Items.Count; i++) { - if (!row.PropertyValues.ContainsKey(elementTypeProp.Alias)) + BlockItemData item = group.Items[i]; + if (!allElementTypes.TryGetValue(item.ContentTypeKey, out IContentType? elementType)) { - // set values to null - row.PropertyValues[elementTypeProp.Alias] = new BlockItemData.BlockPropertyValue(null, elementTypeProp); - row.RawPropertyValues[elementTypeProp.Alias] = null; + throw new InvalidOperationException($"No element type found with key {item.ContentTypeKey}"); } - } - var elementValidation = new ElementTypeValidationModel(row.ContentTypeAlias, row.Key); - foreach (KeyValuePair prop in row.PropertyValues) - { - elementValidation.AddPropertyTypeValidation( - new PropertyTypeValidationModel(prop.Value.PropertyType, prop.Value.Value)); - } + // now ensure missing properties + foreach (IPropertyType elementTypeProp in elementType.CompositionPropertyTypes) + { + if (!item.PropertyValues.ContainsKey(elementTypeProp.Alias)) + { + // set values to null + item.PropertyValues[elementTypeProp.Alias] = new BlockItemData.BlockPropertyValue(null, elementTypeProp); + item.RawPropertyValues[elementTypeProp.Alias] = null; + } + } - yield return elementValidation; + var elementValidation = new ElementTypeValidationModel(item.ContentTypeAlias, item.Key); + foreach (KeyValuePair prop in item.PropertyValues) + { + elementValidation.AddPropertyTypeValidation( + new PropertyTypeValidationModel(prop.Value.PropertyType, prop.Value.Value, $"{group.Path}[{i}].{prop.Value.PropertyType.Alias}")); + } + + yield return elementValidation; + } } } } diff --git a/src/Umbraco.Infrastructure/PropertyEditors/ComplexEditorValidator.cs b/src/Umbraco.Infrastructure/PropertyEditors/ComplexEditorValidator.cs index c573d1edef..9717eac14d 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/ComplexEditorValidator.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/ComplexEditorValidator.cs @@ -34,8 +34,8 @@ public abstract class ComplexEditorValidator : IValueValidator if (rowResults.Count > 0) { - var result = new ComplexEditorValidationResult(); - foreach (ComplexEditorElementTypeValidationResult rowResult in rowResults) + var result = new NestedValidationResults(); + foreach (NestedValidationResults rowResult in rowResults) { result.ValidationResults.Add(rowResult); } @@ -51,23 +51,22 @@ public abstract class ComplexEditorValidator : IValueValidator /// /// Return a nested validation result per row (Element Type) /// - protected IEnumerable GetNestedValidationResults( + protected IEnumerable GetNestedValidationResults( IEnumerable elements) { foreach (ElementTypeValidationModel row in elements) { - var elementTypeValidationResult = - new ComplexEditorElementTypeValidationResult(row.ElementTypeAlias, row.Id); + var elementTypeValidationResult = new NestedValidationResults(); foreach (PropertyTypeValidationModel prop in row.PropertyTypeValidation) { - var propValidationResult = new ComplexEditorPropertyTypeValidationResult(prop.PropertyType.Alias); + var propValidationResult = new NestedJsonPathValidationResults(prop.JsonPath); foreach (ValidationResult validationResult in _propertyValidationService.ValidatePropertyValue( prop.PropertyType, prop.PostedValue)) { // add the result to the property results - propValidationResult.AddValidationResult(validationResult); + propValidationResult.ValidationResults.Add(validationResult); } // add the property results to the element type results @@ -86,15 +85,18 @@ public abstract class ComplexEditorValidator : IValueValidator public class PropertyTypeValidationModel { - public PropertyTypeValidationModel(IPropertyType propertyType, object? postedValue) + public PropertyTypeValidationModel(IPropertyType propertyType, object? postedValue, string jsonPath) { PostedValue = postedValue; PropertyType = propertyType ?? throw new ArgumentNullException(nameof(propertyType)); + JsonPath = jsonPath; } public object? PostedValue { get; } public IPropertyType PropertyType { get; } + + public string JsonPath { get; } } public class ElementTypeValidationModel diff --git a/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs index 775ba3bc6a..cc3c5bece7 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/MultipleTextStringPropertyEditor.cs @@ -2,8 +2,6 @@ // See LICENSE for more details. using System.ComponentModel.DataAnnotations; -using Microsoft.Extensions.DependencyInjection; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Exceptions; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Core.Models; @@ -31,15 +29,6 @@ public class MultipleTextStringPropertyEditor : DataEditor private readonly IEditorConfigurationParser _editorConfigurationParser; private readonly IIOHelper _ioHelper; - // Scheduled for removal in v12 - [Obsolete("Please use constructor that takes an IEditorConfigurationParser instead")] - public MultipleTextStringPropertyEditor( - IIOHelper ioHelper, - IDataValueEditorFactory dataValueEditorFactory) - : this(ioHelper, dataValueEditorFactory, StaticServiceProvider.Instance.GetRequiredService()) - { - } - /// /// Initializes a new instance of the class. /// @@ -70,23 +59,20 @@ public class MultipleTextStringPropertyEditor : DataEditor private static readonly string NewLine = "\n"; private static readonly string[] NewLineDelimiters = { "\r\n", "\r", "\n" }; - private readonly ILocalizedTextService _localizedTextService; - public MultipleTextStringPropertyValueEditor( - ILocalizedTextService localizedTextService, IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, IIOHelper ioHelper, DataEditorAttribute attribute) - : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) => - _localizedTextService = localizedTextService; + : base(shortStringHelper, jsonSerializer, ioHelper, attribute) + { + } /// /// A custom FormatValidator is used as for multiple text strings, each string should individually be checked /// against the configured regular expression, rather than the JSON representing all the strings as a whole. /// - public override IValueFormatValidator FormatValidator => - new MultipleTextStringFormatValidator(_localizedTextService); + public override IValueFormatValidator FormatValidator => new MultipleTextStringFormatValidator(); /// /// The value passed in from the editor will be an array of simple objects so we'll need to parse them to get the @@ -136,11 +122,6 @@ public class MultipleTextStringPropertyEditor : DataEditor internal class MultipleTextStringFormatValidator : IValueFormatValidator { - private readonly ILocalizedTextService _localizedTextService; - - public MultipleTextStringFormatValidator(ILocalizedTextService localizedTextService) => - _localizedTextService = localizedTextService; - public IEnumerable ValidateFormat(object? value, string valueType, string format) { if (value is not IEnumerable textStrings) @@ -148,7 +129,7 @@ public class MultipleTextStringPropertyEditor : DataEditor return Enumerable.Empty(); } - var textStringValidator = new RegexValidator(_localizedTextService); + var textStringValidator = new RegexValidator(); foreach (var textString in textStrings) { var validationResults = textStringValidator.ValidateFormat(textString, valueType, format).ToList(); diff --git a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs index 0a3f2454f7..f71212d6f9 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/NestedContentPropertyEditor.cs @@ -421,7 +421,7 @@ public class NestedContentPropertyEditor : DataEditor row.PropertyValues) { elementValidation.AddPropertyTypeValidation( - new PropertyTypeValidationModel(prop.Value.PropertyType, prop.Value.Value)); + new PropertyTypeValidationModel(prop.Value.PropertyType, prop.Value.Value, string.Empty)); } yield return elementValidation; diff --git a/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs b/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs index 92a7253234..b0f4c54878 100644 --- a/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs +++ b/src/Umbraco.Infrastructure/PropertyEditors/TagsPropertyEditor.cs @@ -30,44 +30,8 @@ public class TagsPropertyEditor : DataEditor private readonly IEditorConfigurationParser _editorConfigurationParser; private readonly ITagPropertyIndexValueFactory _tagPropertyIndexValueFactory; private readonly IIOHelper _ioHelper; - private readonly ILocalizedTextService _localizedTextService; - private readonly ManifestValueValidatorCollection _validators; - - // Scheduled for removal in v12 - [Obsolete("Use non-obsoleted ctor. This will be removed in Umbraco 13.")] - public TagsPropertyEditor( - IDataValueEditorFactory dataValueEditorFactory, - ManifestValueValidatorCollection validators, - IIOHelper ioHelper, - ILocalizedTextService localizedTextService) - : this( - dataValueEditorFactory, - validators, - ioHelper, - localizedTextService, - StaticServiceProvider.Instance.GetRequiredService(), - StaticServiceProvider.Instance.GetRequiredService()) - { - } - - [Obsolete("Use non-obsoleted ctor. This will be removed in Umbraco 13.")] - public TagsPropertyEditor( - IDataValueEditorFactory dataValueEditorFactory, - ManifestValueValidatorCollection validators, - IIOHelper ioHelper, - ILocalizedTextService localizedTextService, - IEditorConfigurationParser editorConfigurationParser) - : this( - dataValueEditorFactory, - validators, - ioHelper, - localizedTextService, - editorConfigurationParser, - StaticServiceProvider.Instance.GetRequiredService()) - { - - } + [Obsolete($"Use the constructor that does not accept {nameof(ILocalizedTextService)}. Will be removed in V15.")] public TagsPropertyEditor( IDataValueEditorFactory dataValueEditorFactory, ManifestValueValidatorCollection validators, @@ -75,11 +39,18 @@ public class TagsPropertyEditor : DataEditor ILocalizedTextService localizedTextService, IEditorConfigurationParser editorConfigurationParser, ITagPropertyIndexValueFactory tagPropertyIndexValueFactory) + : this(dataValueEditorFactory, ioHelper, editorConfigurationParser, tagPropertyIndexValueFactory) + { + } + + public TagsPropertyEditor( + IDataValueEditorFactory dataValueEditorFactory, + IIOHelper ioHelper, + IEditorConfigurationParser editorConfigurationParser, + ITagPropertyIndexValueFactory tagPropertyIndexValueFactory) : base(dataValueEditorFactory) { - _validators = validators; _ioHelper = ioHelper; - _localizedTextService = localizedTextService; _editorConfigurationParser = editorConfigurationParser; _tagPropertyIndexValueFactory = tagPropertyIndexValueFactory; } @@ -91,7 +62,7 @@ public class TagsPropertyEditor : DataEditor DataValueEditorFactory.Create(Attribute!); protected override IConfigurationEditor CreateConfigurationEditor() => - new TagConfigurationEditor(_validators, _ioHelper, _localizedTextService, _editorConfigurationParser); + new TagConfigurationEditor(_ioHelper, _editorConfigurationParser); internal class TagPropertyValueEditor : DataValueEditor, IDataValueTags { @@ -99,13 +70,12 @@ public class TagsPropertyEditor : DataEditor private readonly IDataTypeService _dataTypeService; public TagPropertyValueEditor( - ILocalizedTextService localizedTextService, IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, IIOHelper ioHelper, DataEditorAttribute attribute, IDataTypeService dataTypeService) - : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) + : base(shortStringHelper, jsonSerializer, ioHelper, attribute) { _jsonSerializer = jsonSerializer; _dataTypeService = dataTypeService; diff --git a/src/Umbraco.Core/PropertyEditors/Validation/ComplexEditorElementTypeValidationResult.cs b/src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ComplexEditorElementTypeValidationResult.cs similarity index 95% rename from src/Umbraco.Core/PropertyEditors/Validation/ComplexEditorElementTypeValidationResult.cs rename to src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ComplexEditorElementTypeValidationResult.cs index 1332b0b03c..b18508f8de 100644 --- a/src/Umbraco.Core/PropertyEditors/Validation/ComplexEditorElementTypeValidationResult.cs +++ b/src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ComplexEditorElementTypeValidationResult.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Umbraco.Cms.Core.PropertyEditors.Validation; +namespace Umbraco.Cms.Web.BackOffice.PropertyEditors.Validation; /// /// A collection of for an element type within complex editor diff --git a/src/Umbraco.Core/PropertyEditors/Validation/ComplexEditorPropertyTypeValidationResult.cs b/src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ComplexEditorPropertyTypeValidationResult.cs similarity index 95% rename from src/Umbraco.Core/PropertyEditors/Validation/ComplexEditorPropertyTypeValidationResult.cs rename to src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ComplexEditorPropertyTypeValidationResult.cs index 06749c765a..71ae1cb540 100644 --- a/src/Umbraco.Core/PropertyEditors/Validation/ComplexEditorPropertyTypeValidationResult.cs +++ b/src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ComplexEditorPropertyTypeValidationResult.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Umbraco.Cms.Core.PropertyEditors.Validation; +namespace Umbraco.Cms.Web.BackOffice.PropertyEditors.Validation; /// /// A collection of for a property type within a complex editor represented by an diff --git a/src/Umbraco.Core/PropertyEditors/Validation/ComplexEditorValidationResult.cs b/src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ComplexEditorValidationResult.cs similarity index 92% rename from src/Umbraco.Core/PropertyEditors/Validation/ComplexEditorValidationResult.cs rename to src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ComplexEditorValidationResult.cs index 6ea03ae60f..9f0d1956d3 100644 --- a/src/Umbraco.Core/PropertyEditors/Validation/ComplexEditorValidationResult.cs +++ b/src/Umbraco.Web.BackOffice/PropertyEditors/Validation/ComplexEditorValidationResult.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace Umbraco.Cms.Core.PropertyEditors.Validation; +namespace Umbraco.Cms.Web.BackOffice.PropertyEditors.Validation; /// /// A collection of for a complex editor represented by an diff --git a/tests/Umbraco.Tests.Common/Builders/DataValueEditorBuilder.cs b/tests/Umbraco.Tests.Common/Builders/DataValueEditorBuilder.cs index e6e99c8ca7..81f9b8f687 100644 --- a/tests/Umbraco.Tests.Common/Builders/DataValueEditorBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/DataValueEditorBuilder.cs @@ -55,7 +55,6 @@ public class DataValueEditorBuilder : ChildBuilderBase(), Mock.Of(), Mock.Of()) { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs index eb9f032bfa..e1edbd6179 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/ContentServiceTests.cs @@ -1250,8 +1250,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent Assert.IsFalse(content.HasIdentity); // content cannot publish values because they are invalid - var propertyValidationService = new PropertyValidationService(PropertyEditorCollection, DataTypeService, - TextService, ValueEditorCache); + var propertyValidationService = new PropertyValidationService(PropertyEditorCollection, DataTypeService, ValueEditorCache); var isValid = propertyValidationService.IsPropertyDataValid(content, out var invalidProperties, CultureImpact.Invariant); Assert.IsFalse(isValid); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Create.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Create.cs index a5b6db4dd8..b7d4468470 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Create.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Create.cs @@ -42,10 +42,10 @@ public partial class ContentEditingServiceTests { Assert.IsTrue(result.Success); Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); - VerifyCreate(result.Result); + VerifyCreate(result.Result.Content); // re-get and re-test - VerifyCreate(await ContentEditingService.GetAsync(result.Result!.Key)); + VerifyCreate(await ContentEditingService.GetAsync(result.Result.Content!.Key)); void VerifyCreate(IContent? createdContent) { @@ -61,7 +61,8 @@ public partial class ContentEditingServiceTests { Assert.IsFalse(result.Success); Assert.AreEqual(ContentEditingOperationStatus.NotAllowed, result.Status); - Assert.IsNull(result.Result); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); } } @@ -89,10 +90,10 @@ public partial class ContentEditingServiceTests var rootKey = (await ContentEditingService.CreateAsync( new ContentCreateModel - { - ContentTypeKey = rootContentType.Key, InvariantName = "Root", ParentKey = Constants.System.RootKey, - }, - Constants.Security.SuperUserKey)).Result.Key; + { + ContentTypeKey = rootContentType.Key, InvariantName = "Root", ParentKey = Constants.System.RootKey, + }, + Constants.Security.SuperUserKey)).Result.Content!.Key; var createModel = new ContentCreateModel { @@ -114,7 +115,7 @@ public partial class ContentEditingServiceTests Assert.IsTrue(result.Success); Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); - var createdContent = result.Result; + var createdContent = result.Result.Content; Assert.NotNull(createdContent); Assert.AreNotEqual(Guid.Empty, createdContent.Key); Assert.IsTrue(createdContent.HasIdentity); @@ -126,7 +127,8 @@ public partial class ContentEditingServiceTests { Assert.IsFalse(result.Success); Assert.AreEqual(ContentEditingOperationStatus.NotAllowed, result.Status); - Assert.IsNull(result.Result); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); } } @@ -153,8 +155,8 @@ public partial class ContentEditingServiceTests Assert.IsTrue(result.Success); Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); Assert.IsNotNull(result.Result); - Assert.IsTrue(result.Result.HasIdentity); - Assert.AreEqual("The title value", result.Result.GetValue("title")); + Assert.IsTrue(result.Result.Content!.HasIdentity); + Assert.AreEqual("The title value", result.Result.Content!.GetValue("title")); } [Test] @@ -178,9 +180,56 @@ public partial class ContentEditingServiceTests Assert.IsTrue(result.Success); Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); Assert.IsNotNull(result.Result); - Assert.IsTrue(result.Result.HasIdentity); - Assert.AreEqual(null, result.Result.GetValue("title")); - Assert.AreEqual(null, result.Result.GetValue("bodyText")); + Assert.IsTrue(result.Result.Content!.HasIdentity); + Assert.AreEqual(null, result.Result.Content!.GetValue("title")); + Assert.AreEqual(null, result.Result.Content!.GetValue("bodyText")); + } + + [TestCase(true)] + [TestCase(false)] + public async Task Can_Create_With_Property_Validation(bool addValidProperties) + { + var template = TemplateBuilder.CreateTextPageTemplate(); + await TemplateService.CreateAsync(template, Constants.Security.SuperUserKey); + + var contentType = ContentTypeBuilder.CreateTextPageContentType(defaultTemplateId: template.Id); + contentType.PropertyTypes.First(pt => pt.Alias == "title").Mandatory = true; + contentType.PropertyTypes.First(pt => pt.Alias == "keywords").ValidationRegExp = "^\\d*$"; + contentType.AllowedAsRoot = true; + ContentTypeService.Save(contentType); + + var titleValue = addValidProperties ? "The title value" : null; + var keywordsValue = addValidProperties ? "12345" : "This is not a number"; + var createModel = new ContentCreateModel + { + ContentTypeKey = contentType.Key, + ParentKey = Constants.System.RootKey, + InvariantName = "Test Create", + InvariantProperties = new[] + { + new PropertyValueModel { Alias = "title", Value = titleValue }, + new PropertyValueModel { Alias = "keywords", Value = keywordsValue } + } + }; + + var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); + + // success is expected regardless of property level validation - the validation error status is communicated in the attempt status (see below) + Assert.IsTrue(result.Success); + Assert.AreEqual(addValidProperties ? ContentEditingOperationStatus.Success : ContentEditingOperationStatus.PropertyValidationError, result.Status); + Assert.IsNotNull(result.Result); + + if (addValidProperties is false) + { + Assert.AreEqual(2, result.Result.ValidationResult.ValidationErrors.Count()); + Assert.IsNotNull(result.Result.ValidationResult.ValidationErrors.FirstOrDefault(v => v.Alias == "title" && v.ErrorMessages.Length == 1)); + Assert.IsNotNull(result.Result.ValidationResult.ValidationErrors.FirstOrDefault(v => v.Alias == "keywords" && v.ErrorMessages.Length == 1)); + } + + // NOTE: content creation must be successful, even if the mandatory property is missing (publishing however should not!) + Assert.IsTrue(result.Result.Content!.HasIdentity); + Assert.AreEqual(titleValue, result.Result.Content!.GetValue("title")); + Assert.AreEqual(keywordsValue, result.Result.Content!.GetValue("keywords")); } [Test] @@ -200,7 +249,8 @@ public partial class ContentEditingServiceTests var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); Assert.AreEqual(ContentEditingOperationStatus.ParentNotFound, result.Status); - Assert.IsNull(result.Result); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); } [Test] @@ -216,7 +266,8 @@ public partial class ContentEditingServiceTests var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); Assert.AreEqual(ContentEditingOperationStatus.ContentTypeNotFound, result.Status); - Assert.IsNull(result.Result); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); } [Test] @@ -240,8 +291,8 @@ public partial class ContentEditingServiceTests var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); Assert.AreEqual(ContentEditingOperationStatus.TemplateNotAllowed, result.Status); - Assert.IsNotNull(result.Result); - Assert.IsFalse(result.Result.HasIdentity); + Assert.IsNotNull(result.Result.Content); + Assert.IsFalse(result.Result.Content.HasIdentity); } [Test] @@ -262,8 +313,8 @@ public partial class ContentEditingServiceTests var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); Assert.AreEqual(ContentEditingOperationStatus.TemplateNotFound, result.Status); - Assert.IsNotNull(result.Result); - Assert.IsFalse(result.Result.HasIdentity); + Assert.IsNotNull(result.Result.Content); + Assert.IsFalse(result.Result.Content.HasIdentity); } [Test] @@ -289,7 +340,8 @@ public partial class ContentEditingServiceTests var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); Assert.AreEqual(ContentEditingOperationStatus.PropertyTypeNotFound, result.Status); - Assert.IsNull(result.Result); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); } [Test] @@ -314,7 +366,8 @@ public partial class ContentEditingServiceTests var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); Assert.AreEqual(ContentEditingOperationStatus.ContentTypeCultureVarianceMismatch, result.Status); - Assert.IsNull(result.Result); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); } [Test] @@ -351,7 +404,8 @@ public partial class ContentEditingServiceTests var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); Assert.AreEqual(ContentEditingOperationStatus.ContentTypeCultureVarianceMismatch, result.Status); - Assert.IsNull(result.Result); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); } [Test] @@ -393,10 +447,11 @@ public partial class ContentEditingServiceTests var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); - VerifyCreate(result.Result); + Assert.IsNotNull(result.Result.Content); + VerifyCreate(result.Result.Content); // re-get and re-test - VerifyCreate(await ContentEditingService.GetAsync(result.Result!.Key)); + VerifyCreate(await ContentEditingService.GetAsync(result.Result.Content.Key)); void VerifyCreate(IContent? createdContent) { @@ -433,14 +488,14 @@ public partial class ContentEditingServiceTests var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); - Assert.IsNotNull(result.Result); - Assert.IsTrue(result.Result.HasIdentity); - Assert.AreEqual(key, result.Result.Key); - Assert.AreEqual("The title value", result.Result.GetValue("title")); + Assert.IsNotNull(result.Result.Content); + Assert.IsTrue(result.Result.Content.HasIdentity); + Assert.AreEqual(key, result.Result.Content.Key); + Assert.AreEqual("The title value", result.Result.Content.GetValue("title")); var content = await ContentEditingService.GetAsync(key); Assert.IsNotNull(content); - Assert.AreEqual(result.Result.Id, content.Id); + Assert.AreEqual(result.Result.Content.Id, content.Id); } [Test] @@ -462,7 +517,8 @@ public partial class ContentEditingServiceTests var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); Assert.AreEqual(ContentEditingOperationStatus.PropertyTypeNotFound, result.Status); - Assert.IsNull(result.Result); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); } [Test] @@ -481,7 +537,7 @@ public partial class ContentEditingServiceTests { ContentTypeKey = contentType.Key, InvariantName = "Root", ParentKey = Constants.System.RootKey }, - Constants.Security.SuperUserKey)).Result.Key; + Constants.Security.SuperUserKey)).Result.Content!.Key; await ContentEditingService.MoveToRecycleBinAsync(rootKey, Constants.Security.SuperUserKey); @@ -494,7 +550,8 @@ public partial class ContentEditingServiceTests Assert.IsFalse(result.Success); Assert.AreEqual(ContentEditingOperationStatus.InTrash, result.Status); - Assert.IsNull(result.Result); + Assert.IsNotNull(result.Result); + Assert.IsNull(result.Result.Content); } private void AssertBodyTextEquals(string expected, IContent content) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Sort.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Sort.cs index 0c48d65037..fac576544c 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Sort.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Sort.cs @@ -132,7 +132,7 @@ public partial class ContentEditingServiceTests { ContentTypeKey = rootContentType.Key, InvariantName = "Root", ParentKey = Constants.System.RootKey, }, - Constants.Security.SuperUserKey)).Result!; + Constants.Security.SuperUserKey)).Result.Content!; for (var i = 1; i < 11; i++) { diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Update.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Update.cs index 4b84aee555..2cd2ef8b1d 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Update.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.Update.cs @@ -28,7 +28,7 @@ public partial class ContentEditingServiceTests var result = await ContentEditingService.UpdateAsync(content, updateModel, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); - VerifyUpdate(result.Result); + VerifyUpdate(result.Result.Content); // re-get and re-test VerifyUpdate(await ContentEditingService.GetAsync(content.Key)); @@ -79,7 +79,7 @@ public partial class ContentEditingServiceTests var result = await ContentEditingService.UpdateAsync(content, updateModel, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); - VerifyUpdate(result.Result); + VerifyUpdate(result.Result.Content); // re-get and re-test VerifyUpdate(await ContentEditingService.GetAsync(content.Key)); @@ -113,7 +113,7 @@ public partial class ContentEditingServiceTests }; var result = await ContentEditingService.UpdateAsync(content, updateModel, Constants.Security.SuperUserKey); - VerifyUpdate(result.Result); + VerifyUpdate(result.Result.Content); // re-get and re-test VerifyUpdate(await ContentEditingService.GetAsync(content.Key)); @@ -142,7 +142,7 @@ public partial class ContentEditingServiceTests }; var result = await ContentEditingService.UpdateAsync(content, updateModel, Constants.Security.SuperUserKey); - VerifyUpdate(result.Result); + VerifyUpdate(result.Result.Content); // re-get and re-test VerifyUpdate(await ContentEditingService.GetAsync(content.Key)); @@ -172,7 +172,7 @@ public partial class ContentEditingServiceTests var result = await ContentEditingService.UpdateAsync(content, updateModel, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); Assert.AreEqual(ContentEditingOperationStatus.Success, result.Status); - VerifyUpdate(result.Result); + VerifyUpdate(result.Result.Content); // re-get and re-test VerifyUpdate(await ContentEditingService.GetAsync(content.Key)); @@ -186,6 +186,48 @@ public partial class ContentEditingServiceTests } } + [TestCase(true)] + [TestCase(false)] + public async Task Can_Update_With_Property_Validation(bool addValidProperties) + { + var content = await CreateInvariantContent(); + var contentType = await ContentTypeService.GetAsync(content.ContentType.Key)!; + contentType.PropertyTypes.First(pt => pt.Alias == "title").Mandatory = true; + contentType.PropertyTypes.First(pt => pt.Alias == "text").ValidationRegExp = "^\\d*$"; + await ContentTypeService.SaveAsync(contentType, Constants.Security.SuperUserKey); + + var titleValue = addValidProperties ? "The title value" : null; + var textValue = addValidProperties ? "12345" : "This is not a number"; + + var updateModel = new ContentUpdateModel + { + InvariantName = content.Name, + InvariantProperties = new[] + { + new PropertyValueModel { Alias = "title", Value = titleValue }, + new PropertyValueModel { Alias = "text", Value = textValue } + } + }; + + var result = await ContentEditingService.UpdateAsync(content, updateModel, Constants.Security.SuperUserKey); + + // success is expected regardless of property level validation - the validation error status is communicated in the attempt status (see below) + Assert.IsTrue(result.Success); + Assert.AreEqual(addValidProperties ? ContentEditingOperationStatus.Success : ContentEditingOperationStatus.PropertyValidationError, result.Status); + Assert.IsNotNull(result.Result); + + if (addValidProperties is false) + { + Assert.AreEqual(2, result.Result.ValidationResult.ValidationErrors.Count()); + Assert.IsNotNull(result.Result.ValidationResult.ValidationErrors.FirstOrDefault(v => v.Alias == "title" && v.ErrorMessages.Length == 1)); + Assert.IsNotNull(result.Result.ValidationResult.ValidationErrors.FirstOrDefault(v => v.Alias == "text" && v.ErrorMessages.Length == 1)); + } + + // NOTE: content update must be successful, even if the mandatory property is missing (publishing however should not!) + Assert.AreEqual(titleValue, result.Result.Content!.GetValue("title")); + Assert.AreEqual(textValue, result.Result.Content!.GetValue("text")); + } + [Test] public async Task Cannot_Update_With_Variant_Property_Value_For_Invariant_Content() { @@ -242,7 +284,7 @@ public partial class ContentEditingServiceTests var result = await ContentEditingService.UpdateAsync(content, updateModel, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); Assert.AreEqual(ContentEditingOperationStatus.PropertyTypeNotFound, result.Status); - Assert.IsNotNull(result.Result); + Assert.IsNotNull(result.Result.Content); // re-get and validate content = await ContentEditingService.GetAsync(content.Key); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs index 31d94298c2..115d95fa05 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentEditingServiceTests.cs @@ -127,7 +127,7 @@ public partial class ContentEditingServiceTests : UmbracoIntegrationTestWithCont var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); - return result.Result!; + return result.Result.Content!; } private async Task CreateVariantContent() @@ -167,7 +167,7 @@ public partial class ContentEditingServiceTests : UmbracoIntegrationTestWithCont var result = await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); - return result.Result!; + return result.Result.Content!; } private async Task CreateTextPageContentTypeAsync() @@ -191,7 +191,7 @@ public partial class ContentEditingServiceTests : UmbracoIntegrationTestWithCont InvariantName = rootName }; - var root = (await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey)).Result!; + var root = (await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey)).Result.Content!; contentType.AllowedContentTypes = new List { @@ -202,7 +202,7 @@ public partial class ContentEditingServiceTests : UmbracoIntegrationTestWithCont createModel.ParentKey = root.Key; createModel.InvariantName = childName; - var child = (await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey)).Result!; + var child = (await ContentEditingService.CreateAsync(createModel, Constants.Security.SuperUserKey)).Result.Content!; Assert.AreEqual(root.Id, child.ParentId); return (root, child); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Publish.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Publish.cs index e0a22889d8..05ce78e831 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Publish.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.Publish.cs @@ -1,14 +1,9 @@ using NUnit.Framework; using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Notifications; -using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Models.ContentPublishing; using Umbraco.Cms.Core.Services.OperationStatus; using Umbraco.Cms.Tests.Common.Builders; -using Umbraco.Cms.Tests.Common.Builders.Extensions; -using Umbraco.Cms.Tests.Common.Testing; -using Umbraco.Cms.Tests.Integration.Testing; namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; @@ -22,7 +17,7 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishAsync(Textpage.Key, _allCultures, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result); + Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Status); VerifyIsPublished(Textpage.Key); } @@ -43,7 +38,7 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishAsync(Subpage.Key, _allCultures, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result); + Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Status); VerifyIsPublished(Subpage.Key); } @@ -59,19 +54,14 @@ public partial class ContentPublishingServiceTests if (force) { - Assert.AreEqual(4, result.Result.Count); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[Textpage.Key]); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[Subpage.Key]); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[Subpage2.Key]); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[Subpage3.Key]); + AssertBranchResultSuccess(result.Result, Textpage.Key, Subpage.Key, Subpage2.Key, Subpage3.Key); VerifyIsPublished(Subpage.Key); VerifyIsPublished(Subpage2.Key); VerifyIsPublished(Subpage3.Key); } else { - Assert.AreEqual(1, result.Result.Count); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[Textpage.Key]); + AssertBranchResultSuccess(result.Result, Textpage.Key); VerifyIsNotPublished(Subpage.Key); VerifyIsNotPublished(Subpage2.Key); VerifyIsNotPublished(Subpage3.Key); @@ -89,9 +79,7 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishBranchAsync(Subpage2.Key, _allCultures, true, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); - Assert.AreEqual(2, result.Result.Count); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[Subpage2.Key]); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[subpage2Subpage.Key]); + AssertBranchResultSuccess(result.Result, Subpage2.Key, subpage2Subpage.Key); VerifyIsPublished(Subpage2.Key); VerifyIsPublished(subpage2Subpage.Key); VerifyIsNotPublished(Subpage.Key); @@ -105,7 +93,7 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishAsync(Textpage.Key, _allCultures, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); - Assert.AreEqual(ContentPublishingOperationStatus.CancelledByEvent, result.Result); + Assert.AreEqual(ContentPublishingOperationStatus.CancelledByEvent, result.Status); } [Test] @@ -125,7 +113,7 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishAsync(content.Key, new[] { langEn.IsoCode, langDa.IsoCode }, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result); + Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Status); content = ContentService.GetById(content.Key)!; Assert.AreEqual(2, content.PublishedCultures.Count()); @@ -147,28 +135,28 @@ public partial class ContentPublishingServiceTests content.SetValue("title", "DA title", culture: langDa.IsoCode); ContentService.Save(content); - var result = await ContentPublishingService.PublishAsync(content.Key, new[] { langEn.IsoCode, langDa.IsoCode }, Constants.Security.SuperUserKey); + var publishResult = await ContentPublishingService.PublishAsync(content.Key, new[] { langEn.IsoCode, langDa.IsoCode }, Constants.Security.SuperUserKey); - Assert.IsTrue(result.Success); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result); + Assert.IsTrue(publishResult.Success); + Assert.AreEqual(ContentPublishingOperationStatus.Success, publishResult.Status); content = ContentService.GetById(content.Key)!; Assert.AreEqual(2, content.PublishedCultures.Count()); Assert.IsTrue(content.PublishedCultures.InvariantContains(langEn.IsoCode)); Assert.IsTrue(content.PublishedCultures.InvariantContains(langDa.IsoCode)); - result = await ContentPublishingService.UnpublishAsync(content.Key, "*", Constants.Security.SuperUserKey); + var unpublishResult = await ContentPublishingService.UnpublishAsync(content.Key, "*", Constants.Security.SuperUserKey); - Assert.IsTrue(result.Success); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result); + Assert.IsTrue(unpublishResult.Success); + Assert.AreEqual(ContentPublishingOperationStatus.Success, unpublishResult.Result); content = ContentService.GetById(content.Key)!; Assert.AreEqual(2, content.PublishedCultures.Count()); - result = await ContentPublishingService.PublishAsync(content.Key, new[] { langDa.IsoCode }, Constants.Security.SuperUserKey); + publishResult = await ContentPublishingService.PublishAsync(content.Key, new[] { langDa.IsoCode }, Constants.Security.SuperUserKey); - Assert.IsTrue(result.Success); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result); + Assert.IsTrue(publishResult.Success); + Assert.AreEqual(ContentPublishingOperationStatus.Success, publishResult.Status); content = ContentService.GetById(content.Key)!; // FIXME: when work item 32809 has been fixed, this should assert for 1 expected published cultures @@ -202,9 +190,7 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode, langDa.IsoCode }, true, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); - Assert.AreEqual(2, result.Result!.Count); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[root.Key]); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[child.Key]); + AssertBranchResultSuccess(result.Result, root.Key, child.Key); root = ContentService.GetById(root.Key)!; Assert.AreEqual(2, root.PublishedCultures.Count()); @@ -233,7 +219,7 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishAsync(content.Key, new[] { langEn.IsoCode }, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result); + Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Status); content = ContentService.GetById(content.Key)!; Assert.AreEqual(1, content.PublishedCultures.Count()); @@ -266,9 +252,7 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode }, true, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); - Assert.AreEqual(2, result.Result!.Count); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[root.Key]); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[child.Key]); + AssertBranchResultSuccess(result.Result, root.Key, child.Key); root = ContentService.GetById(root.Key)!; Assert.AreEqual(1, root.PublishedCultures.Count()); @@ -307,9 +291,7 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode }, true, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); - Assert.AreEqual(2, result.Result!.Count); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[root.Key]); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[child.Key]); + AssertBranchResultSuccess(result.Result, root.Key, child.Key); root = ContentService.GetById(root.Key)!; Assert.AreEqual(1, root.PublishedCultures.Count()); @@ -336,7 +318,7 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishAsync(content.Key, new[] { langEn.IsoCode, langDa.IsoCode }, Constants.Security.SuperUserKey); Assert.IsTrue(result.Success); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result); + Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Status); content = ContentService.GetById(content.Key)!; Assert.AreEqual(2, content.PublishedCultures.Count()); @@ -347,7 +329,7 @@ public partial class ContentPublishingServiceTests { var result = await ContentPublishingService.PublishAsync(Guid.NewGuid(), _allCultures, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); - Assert.AreEqual(ContentPublishingOperationStatus.ContentNotFound, result.Result); + Assert.AreEqual(ContentPublishingOperationStatus.ContentNotFound, result.Status); } [Test] @@ -356,7 +338,7 @@ public partial class ContentPublishingServiceTests var key = Guid.NewGuid(); var result = await ContentPublishingService.PublishBranchAsync(key, _allCultures, true, Constants.Security.SuperUserKey); Assert.IsFalse(result); - Assert.AreEqual(ContentPublishingOperationStatus.ContentNotFound, result.Result[key]); + AssertBranchResultFailed(result.Result, (key, ContentPublishingOperationStatus.ContentNotFound)); } [Test] @@ -367,7 +349,14 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishAsync(content.Key, _allCultures, Constants.Security.SuperUserKey); Assert.IsFalse(result); - Assert.AreEqual(ContentPublishingOperationStatus.ContentInvalid, result.Result); + Assert.AreEqual(ContentPublishingOperationStatus.ContentInvalid, result.Status); + + var invalidPropertyAliases = result.Result.InvalidPropertyAliases.ToArray(); + Assert.AreEqual(3, invalidPropertyAliases.Length); + Assert.Contains("title", invalidPropertyAliases); + Assert.Contains("bodyText", invalidPropertyAliases); + Assert.Contains("author", invalidPropertyAliases); + VerifyIsNotPublished(content.Key); } @@ -381,13 +370,9 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, true, Constants.Security.SuperUserKey); - Assert.IsFalse(result); - Assert.AreEqual(5, result.Result.Count); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[Textpage.Key]); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[Subpage.Key]); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[Subpage2.Key]); - Assert.AreEqual(ContentPublishingOperationStatus.Success, result.Result[Subpage3.Key]); - Assert.AreEqual(ContentPublishingOperationStatus.ContentInvalid, result.Result[content.Key]); + Assert.IsFalse(result.Success); + AssertBranchResultSuccess(result.Result, Textpage.Key, Subpage.Key, Subpage2.Key, Subpage3.Key); + AssertBranchResultFailed(result.Result, (content.Key, ContentPublishingOperationStatus.ContentInvalid)); VerifyIsPublished(Textpage.Key); VerifyIsPublished(Subpage.Key); VerifyIsPublished(Subpage2.Key); @@ -412,7 +397,7 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishAsync(content.Key, new[] { langEn.IsoCode, langDa.IsoCode }, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); - Assert.AreEqual(ContentPublishingOperationStatus.ContentInvalid, result.Result); + Assert.AreEqual(ContentPublishingOperationStatus.ContentInvalid, result.Status); content = ContentService.GetById(content.Key)!; Assert.AreEqual(0, content.PublishedCultures.Count()); @@ -434,7 +419,7 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishAsync(content.Key, new[] { langDa.IsoCode }, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); - Assert.AreEqual(ContentPublishingOperationStatus.MandatoryCultureMissing, result.Result); + Assert.AreEqual(ContentPublishingOperationStatus.MandatoryCultureMissing, result.Status); content = ContentService.GetById(content.Key)!; Assert.AreEqual(0, content.PublishedCultures.Count()); @@ -466,8 +451,7 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishBranchAsync(root.Key, new[] { langEn.IsoCode, langDa.IsoCode }, true, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); - Assert.AreEqual(1, result.Result!.Count); - Assert.AreEqual(ContentPublishingOperationStatus.ContentInvalid, result.Result[root.Key]); + AssertBranchResultFailed(result.Result, (root.Key, ContentPublishingOperationStatus.ContentInvalid)); root = ContentService.GetById(root.Key)!; Assert.AreEqual(0, root.PublishedCultures.Count()); @@ -484,7 +468,7 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishAsync(Subpage.Key, _allCultures, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); - Assert.AreEqual(ContentPublishingOperationStatus.PathNotPublished, result.Result); + Assert.AreEqual(ContentPublishingOperationStatus.PathNotPublished, result.Status); VerifyIsNotPublished(Subpage.Key); } @@ -497,7 +481,135 @@ public partial class ContentPublishingServiceTests var result = await ContentPublishingService.PublishAsync(Subpage.Key, _allCultures, Constants.Security.SuperUserKey); Assert.IsFalse(result.Success); - Assert.AreEqual(ContentPublishingOperationStatus.InTrash, result.Result); + Assert.AreEqual(ContentPublishingOperationStatus.InTrash, result.Status); VerifyIsNotPublished(Subpage.Key); } + + [Test] + public async Task Cannot_Republish_Content_After_Adding_Validation_To_Existing_Property() + { + Textpage.SetValue("title", string.Empty); + Textpage.SetValue("author", "This is not a number"); + ContentService.Save(Textpage); + + var result = await ContentPublishingService.PublishAsync(Textpage.Key, _allCultures, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + VerifyIsPublished(Textpage.Key); + + ContentType.PropertyTypes.First(pt => pt.Alias == "title").Mandatory = true; + ContentType.PropertyTypes.First(pt => pt.Alias == "author").ValidationRegExp = "^\\d*$"; + await ContentTypeService.SaveAsync(ContentType, Constants.Security.SuperUserKey); + + result = await ContentPublishingService.PublishAsync(Textpage.Key, _allCultures, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentPublishingOperationStatus.ContentInvalid, result.Status); + + var invalidPropertyAliases = result.Result.InvalidPropertyAliases.ToArray(); + Assert.AreEqual(2, invalidPropertyAliases.Length); + Assert.Contains("title", invalidPropertyAliases); + Assert.Contains("author", invalidPropertyAliases); + + // despite the failure to publish, the page should remain published + VerifyIsPublished(Textpage.Key); + } + + [Test] + public async Task Cannot_Republish_Content_After_Adding_Mandatory_Property() + { + var result = await ContentPublishingService.PublishAsync(Textpage.Key, _allCultures, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + VerifyIsPublished(Textpage.Key); + + ContentType.AddPropertyType( + new PropertyType(ShortStringHelper, Constants.PropertyEditors.Aliases.TextBox, ValueStorageType.Nvarchar) + { + Alias = "mandatoryProperty", Name = "Mandatory Property", Mandatory = true + }); + await ContentTypeService.SaveAsync(ContentType, Constants.Security.SuperUserKey); + + result = await ContentPublishingService.PublishAsync(Textpage.Key, _allCultures, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentPublishingOperationStatus.ContentInvalid, result.Status); + + var invalidPropertyAliases = result.Result.InvalidPropertyAliases.ToArray(); + Assert.AreEqual(1, invalidPropertyAliases.Length); + Assert.Contains("mandatoryProperty", invalidPropertyAliases); + + // despite the failure to publish, the page should remain published + VerifyIsPublished(Textpage.Key); + } + + [Test] + public async Task Cannot_Republish_Branch_After_Adding_Mandatory_Property() + { + var result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, true, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success); + VerifyIsPublished(Textpage.Key); + VerifyIsPublished(Subpage.Key); + VerifyIsPublished(Subpage2.Key); + VerifyIsPublished(Subpage3.Key); + + // force an update on the child pages so they will be subject to branch republishing + foreach (var key in new [] { Subpage.Key, Subpage2.Key, Subpage3.Key }) + { + var content = ContentService.GetById(key)!; + content.SetValue("title", "Updated"); + ContentService.Save(content); + } + + ContentType.AddPropertyType( + new PropertyType(ShortStringHelper, Constants.PropertyEditors.Aliases.TextBox, ValueStorageType.Nvarchar) + { + Alias = "mandatoryProperty", Name = "Mandatory Property", Mandatory = true + }); + await ContentTypeService.SaveAsync(ContentType, Constants.Security.SuperUserKey); + + // force an update on the root page so it is valid (and also subject to branch republishing). + // if we didn't do this, the children would never be considered for branch publishing, as the publish logic + // stops at the first invalid parent. + // as an added bonus, this lets us test a partially successful branch publish :) + var textPage = ContentService.GetById(Textpage.Key)!; + textPage.SetValue("mandatoryProperty", "This is a valid value"); + ContentService.Save(textPage); + + result = await ContentPublishingService.PublishBranchAsync(Textpage.Key, _allCultures, true, Constants.Security.SuperUserKey); + Assert.IsFalse(result.Success); + Assert.AreEqual(ContentPublishingOperationStatus.FailedBranch, result.Status); + AssertBranchResultSuccess(result.Result, Textpage.Key); + AssertBranchResultFailed( + result.Result, + (Subpage.Key, ContentPublishingOperationStatus.ContentInvalid), + (Subpage2.Key, ContentPublishingOperationStatus.ContentInvalid), + (Subpage3.Key, ContentPublishingOperationStatus.ContentInvalid)); + + // despite the failure to publish, the entier branch should remain published + VerifyIsPublished(Textpage.Key); + VerifyIsPublished(Subpage.Key); + VerifyIsPublished(Subpage2.Key); + VerifyIsPublished(Subpage3.Key); + } + + private void AssertBranchResultSuccess(ContentPublishingBranchResult result, params Guid[] expectedKeys) + { + var items = result.SucceededItems.ToArray(); + Assert.AreEqual(expectedKeys.Length, items.Length); + foreach (var key in expectedKeys) + { + var item = items.FirstOrDefault(i => i.Key == key); + Assert.IsNotNull(item); + Assert.AreEqual(ContentPublishingOperationStatus.Success, item.OperationStatus); + } + } + + private void AssertBranchResultFailed(ContentPublishingBranchResult result, params (Guid, ContentPublishingOperationStatus)[] expectedFailures) + { + var items = result.FailedItems.ToArray(); + Assert.AreEqual(expectedFailures.Length, items.Length); + foreach (var (key, status) in expectedFailures) + { + var item = items.FirstOrDefault(i => i.Key == key); + Assert.IsNotNull(item); + Assert.AreEqual(status, item.OperationStatus); + } + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.cs index 9a2163b3bb..3bc01c98b1 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentPublishingServiceTests.cs @@ -42,6 +42,8 @@ public partial class ContentPublishingServiceTests : UmbracoIntegrationTestWithC ContentTypeService.Save(ContentType); var content = ContentBuilder.CreateSimpleContent(contentType, "Invalid Content", parent?.Id ?? Constants.System.Root); + content.SetValue("title", string.Empty); + content.SetValue("bodyText", string.Empty); content.SetValue("author", string.Empty); ContentService.Save(content); diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentValidationServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentValidationServiceTests.cs new file mode 100644 index 0000000000..76bb7c6841 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentValidationServiceTests.cs @@ -0,0 +1,374 @@ +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.Serialization; +using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +[TestFixture] +[UmbracoTest( + Database = UmbracoTestOptions.Database.NewSchemaPerTest, + PublishedRepositoryEvents = true, + WithApplication = true)] +public class ContentValidationServiceTests : UmbracoIntegrationTestWithContent +{ + private IContentValidationService ContentValidationService => GetRequiredService(); + + protected override void ConfigureTestServices(IServiceCollection services) + { + // block list requires System.Text.Json as serializer - currently we still perform fallback to Json.NET in tests + services.AddSingleton(); + services.AddSingleton(); + } + + [Test] + public async Task Can_Validate_Block_List_Nested_In_Block_List() + { + var setup = await SetupBlockListTest(); + + var validationResult = await ContentValidationService.ValidatePropertiesAsync( + new ContentCreateModel + { + ContentTypeKey = setup.DocumentType.Key, + InvariantName = "Test Document", + InvariantProperties = new[] + { + new PropertyValueModel + { + Alias = "blocks", + Value = $$""" + { + "layout": { + "Umbraco.BlockList": [{ + "contentUdi": "umb://element/9addc377c02c4db088c273b933704f7b", + "settingsUdi": "umb://element/65db1ecd78e041a584f07296123a0a73" + }, { + "contentUdi": "umb://element/3af93b5b5e404c64b1422564309fc4c7", + "settingsUdi": "umb://element/efb9583ce67043f282fb2a0cb0f3e736" + } + ] + }, + "contentData": [{ + "contentTypeKey": "{{setup.ElementType.Key}}", + "udi": "umb://element/9addc377c02c4db088c273b933704f7b", + "title": "Valid root content", + "blocks": { + "layout": { + "Umbraco.BlockList": [{ + "contentUdi": "umb://element/f36cebfad03b44519e604bf32c5b1e2f", + "settingsUdi": "umb://element/c9129a4671bb4b4e8f0ad525ad4a5de3" + }, { + "contentUdi": "umb://element/b8173e4a0618475c8277c3c6af68bee6", + "settingsUdi": "umb://element/77f7ea3507664395bf7f0c9df04530f7" + } + ] + }, + "contentData": [{ + "contentTypeKey": "{{setup.ElementType.Key}}", + "udi": "umb://element/f36cebfad03b44519e604bf32c5b1e2f", + "title": "Invalid nested content" + }, { + "contentTypeKey": "{{setup.ElementType.Key}}", + "udi": "umb://element/b8173e4a0618475c8277c3c6af68bee6", + "title": "Valid nested content" + } + ], + "settingsData": [{ + "contentTypeKey": "{{setup.ElementType.Key}}", + "udi": "umb://element/c9129a4671bb4b4e8f0ad525ad4a5de3", + "title": "Valid nested setting" + }, { + "contentTypeKey": "{{setup.ElementType.Key}}", + "udi": "umb://element/77f7ea3507664395bf7f0c9df04530f7", + "title": "Invalid nested setting" + } + ] + } + }, { + "contentTypeKey": "{{setup.ElementType.Key}}", + "udi": "umb://element/3af93b5b5e404c64b1422564309fc4c7", + "title": "Invalid root content" + } + ], + "settingsData": [{ + "contentTypeKey": "{{setup.ElementType.Key}}", + "udi": "umb://element/65db1ecd78e041a584f07296123a0a73", + "title": "Invalid root setting" + }, { + "contentTypeKey": "{{setup.ElementType.Key}}", + "udi": "umb://element/efb9583ce67043f282fb2a0cb0f3e736", + "title": "Valid root setting" + } + ] + } + """ + } + } + }, + setup.DocumentType); + + Assert.AreEqual(4, validationResult.ValidationErrors.Count()); + Assert.IsNotNull(validationResult.ValidationErrors.SingleOrDefault(r => r.Alias == "blocks" && r.JsonPath == ".contentData[0].blocks.contentData[0].title")); + Assert.IsNotNull(validationResult.ValidationErrors.SingleOrDefault(r => r.Alias == "blocks" && r.JsonPath == ".contentData[0].blocks.settingsData[1].title")); + Assert.IsNotNull(validationResult.ValidationErrors.SingleOrDefault(r => r.Alias == "blocks" && r.JsonPath == ".contentData[1].title")); + Assert.IsNotNull(validationResult.ValidationErrors.SingleOrDefault(r => r.Alias == "blocks" && r.JsonPath == ".settingsData[0].title")); + } + + [TestCase(true)] + [TestCase(false)] + public async Task Can_Validate_RegEx_For_Simple_Property_On_Document(bool valid) + { + var contentType = SetupSimpleTest(); + + var validationResult = await ContentValidationService.ValidatePropertiesAsync( + new ContentCreateModel + { + ContentTypeKey = contentType.Key, + InvariantName = "Test Document", + InvariantProperties = new[] + { + new PropertyValueModel + { + Alias = "title", + Value = "The title value" + }, + new PropertyValueModel + { + Alias = "author", + Value = valid ? "Valid value" : "Invalid value" + } + } + }, + contentType); + + if (valid) + { + Assert.IsEmpty(validationResult.ValidationErrors); + } + else + { + Assert.AreEqual(1, validationResult.ValidationErrors.Count()); + Assert.IsNotNull(validationResult.ValidationErrors.SingleOrDefault(r => r.Alias == "author" && r.JsonPath == string.Empty)); + } + } + + [TestCase(true)] + [TestCase(false)] + public async Task Can_Validate_Mandatory_For_Simple_Property_On_Document(bool valid) + { + var contentType = SetupSimpleTest(); + + var validationResult = await ContentValidationService.ValidatePropertiesAsync( + new ContentCreateModel + { + ContentTypeKey = contentType.Key, + InvariantName = "Test Document", + InvariantProperties = new[] + { + new PropertyValueModel + { + Alias = "title", + Value = valid ? "A value" : string.Empty + }, + new PropertyValueModel + { + Alias = "author", + Value = "Valid value" + } + } + }, + contentType); + + if (valid) + { + Assert.IsEmpty(validationResult.ValidationErrors); + } + else + { + Assert.AreEqual(1, validationResult.ValidationErrors.Count()); + Assert.IsNotNull(validationResult.ValidationErrors.SingleOrDefault(r => r.Alias == "title" && r.JsonPath == string.Empty)); + } + } + + [Test] + public async Task Can_Validate_Mandatory_For_Property_Not_Present_In_Document() + { + var contentType = SetupSimpleTest(); + + var validationResult = await ContentValidationService.ValidatePropertiesAsync( + new ContentCreateModel + { + ContentTypeKey = contentType.Key, + InvariantName = "Test Document", + InvariantProperties = new[] + { + new PropertyValueModel + { + Alias = "author", + Value = "Valid value" + } + } + }, + contentType); + + Assert.AreEqual(1, validationResult.ValidationErrors.Count()); + Assert.IsNotNull(validationResult.ValidationErrors.SingleOrDefault(r => r.Alias == "title" && r.JsonPath == string.Empty)); + } + + [Test] + public async Task Uses_Localizaton_Keys_For_Validation_Error_Messages() + { + var contentType = SetupSimpleTest(); + + var validationResult = await ContentValidationService.ValidatePropertiesAsync( + new ContentCreateModel + { + ContentTypeKey = contentType.Key, + InvariantName = "Test Document", + InvariantProperties = new[] + { + new PropertyValueModel + { + Alias = "author", + Value = "Invalid value" + } + } + }, + contentType); + + Assert.AreEqual(2, validationResult.ValidationErrors.Count()); + Assert.IsNotNull(validationResult.ValidationErrors.SingleOrDefault( + r => r.Alias == "title" + && r.ErrorMessages.Length == 1 + && r.ErrorMessages.First() == Constants.Validation.ErrorMessages.Properties.Missing)); + Assert.IsNotNull(validationResult.ValidationErrors.SingleOrDefault( + r => r.Alias == "author" + && r.ErrorMessages.Length == 1 + && r.ErrorMessages.First() == Constants.Validation.ErrorMessages.Properties.PatternMismatch)); + } + + [Test] + public async Task Custom_Validation_Error_Messages_Replaces_Localizaton_Keys() + { + var contentType = SetupSimpleTest(); + contentType.PropertyTypes.First(pt => pt.Alias == "title").MandatoryMessage = "Custom mandatory message"; + contentType.PropertyTypes.First(pt => pt.Alias == "author").ValidationRegExpMessage = "Custom regex message"; + ContentTypeService.Save(contentType); + + var validationResult = await ContentValidationService.ValidatePropertiesAsync( + new ContentCreateModel + { + ContentTypeKey = contentType.Key, + InvariantName = "Test Document", + InvariantProperties = new[] + { + new PropertyValueModel + { + Alias = "author", + Value = "Invalid value" + } + } + }, + contentType); + + Assert.AreEqual(2, validationResult.ValidationErrors.Count()); + Assert.IsNotNull(validationResult.ValidationErrors.SingleOrDefault( + r => r.Alias == "title" + && r.ErrorMessages.Length == 1 + && r.ErrorMessages.First() == "Custom mandatory message")); + Assert.IsNotNull(validationResult.ValidationErrors.SingleOrDefault( + r => r.Alias == "author" + && r.ErrorMessages.Length == 1 + && r.ErrorMessages.First() == "Custom regex message")); + } + + private async Task<(IContentType DocumentType, IContentType ElementType)> SetupBlockListTest() + { + var propertyEditorCollection = GetRequiredService(); + if (propertyEditorCollection.TryGet(Constants.PropertyEditors.Aliases.BlockList, out IDataEditor dataEditor) is false) + { + Assert.Fail("Could not get the Block List data editor"); + } + + var elementType = new ContentType(ShortStringHelper, Constants.System.Root) + { + Name = "Test Element Type", Alias = "testElementType", IsElement = true + }; + await ContentTypeService.SaveAsync(elementType, Constants.Security.SuperUserKey); + Assert.IsTrue(elementType.HasIdentity, "Could not create the element type"); + + var configurationEditorJsonSerializer = GetRequiredService(); + IDataType blockListDataType = new DataType(dataEditor, configurationEditorJsonSerializer) + { + Name = "Test Block List", + ParentId = Constants.System.Root, + DatabaseType = ValueTypes.ToStorageType(dataEditor.GetValueEditor().ValueType), + ConfigurationData = new Dictionary + { + { + nameof(BlockListConfiguration.Blocks).ToFirstLowerInvariant(), + new[] + { + new BlockListConfiguration + { + Blocks = new[] + { + new BlockListConfiguration.BlockConfiguration + { + ContentElementTypeKey = elementType.Key, + SettingsElementTypeKey = elementType.Key + } + } + } + } + } + } + }; + + var dataTypeService = GetRequiredService(); + var dataTypeCreateResult = await dataTypeService.CreateAsync(blockListDataType, Constants.Security.SuperUserKey); + Assert.IsTrue(dataTypeCreateResult.Success, "Could not create the block list data type"); + + blockListDataType = dataTypeCreateResult.Result; + + // add the block list and a regex validated text box to the element type + elementType.AddPropertyType(new PropertyType(ShortStringHelper, blockListDataType, "blocks")); + var textBoxDataType = await dataTypeService.GetAsync("Textstring"); + Assert.IsNotNull(textBoxDataType, "Could not get the default TextBox data type"); + elementType.AddPropertyType(new PropertyType(ShortStringHelper, textBoxDataType, "title") + { + ValidationRegExp = "^Valid.*$" + }); + await ContentTypeService.SaveAsync(elementType, Constants.Security.SuperUserKey); + + // create a document type with the block list and a regex validated text box + var documentType = new ContentType(ShortStringHelper, Constants.System.Root) + { + Name = "Test Document Type", Alias = "testDocumentType", IsElement = false, AllowedAsRoot = true + }; + documentType.AddPropertyType(new PropertyType(ShortStringHelper, blockListDataType, "blocks")); + await ContentTypeService.SaveAsync(documentType, Constants.Security.SuperUserKey); + Assert.IsTrue(documentType.HasIdentity, "Could not create the document type"); + + return (documentType, elementType); + } + + private IContentType SetupSimpleTest() + { + var contentType = ContentTypeBuilder.CreateSimpleContentType("umbMandatory", "Mandatory Doc Type"); + contentType.PropertyTypes.First(pt => pt.Alias == "title").Mandatory = true; + contentType.PropertyTypes.First(pt => pt.Alias == "author").ValidationRegExp = "^Valid.*$"; + contentType.AllowedAsRoot = true; + ContentTypeService.Save(contentType); + + return contentType; + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Filters/ContentModelValidatorTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Filters/ContentModelValidatorTests.cs deleted file mode 100644 index f1d9c2772e..0000000000 --- a/tests/Umbraco.Tests.Integration/Umbraco.Web.BackOffice/Filters/ContentModelValidatorTests.cs +++ /dev/null @@ -1,341 +0,0 @@ -// Copyright (c) Umbraco. -// See LICENSE for more details. - -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using NUnit.Framework; -using Umbraco.Cms.Core.IO; -using Umbraco.Cms.Core.Mapping; -using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Models.ContentEditing; -using Umbraco.Cms.Core.PropertyEditors; -using Umbraco.Cms.Core.Serialization; -using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Core.Strings; -using Umbraco.Cms.Tests.Common.Builders; -using Umbraco.Cms.Tests.Common.Testing; -using Umbraco.Cms.Tests.Integration.Testing; -using Umbraco.Cms.Web.BackOffice.Filters; -using Umbraco.Cms.Web.BackOffice.ModelBinders; -using Umbraco.Extensions; -using DataType = Umbraco.Cms.Core.Models.DataType; - -namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.BackOffice.Filters; - -[TestFixture] -[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Mapper = true, WithApplication = true, Logger = UmbracoTestOptions.Logger.Console)] -public class ContentModelValidatorTests : UmbracoIntegrationTest -{ - [SetUp] - public void SetUp() - { - var complexEditorConfig = new NestedContentConfiguration - { - ContentTypes = new[] { new NestedContentConfiguration.ContentType { Alias = "feature" } } - }; - - var complexTestEditor = Services.GetRequiredService(); - var testEditor = Services.GetRequiredService(); - var dataTypeService = Services.GetRequiredService(); - var serializer = Services.GetRequiredService(); - - var complexDataType = new DataType(complexTestEditor, serializer) - { - Name = "ComplexTest" - }; - - complexDataType.ConfigurationData = complexDataType.Editor!.GetConfigurationEditor() - .FromConfigurationObject( - complexEditorConfig, - serializer); - - var configuration = complexDataType.ConfigurationObject as NestedContentConfiguration; - Assert.NotNull(configuration); - Assert.AreEqual(1, configuration.ContentTypes!.Length); - Assert.AreEqual("feature", configuration.ContentTypes.First().Alias); - - var testDataType = new DataType(testEditor, serializer) { Name = "Test" }; - dataTypeService.Save(complexDataType); - dataTypeService.Save(testDataType); - - var fileService = Services.GetRequiredService(); - var template = TemplateBuilder.CreateTextPageTemplate(); - fileService.SaveTemplate(template); - - _contentType = ContentTypeBuilder.CreateTextPageContentType(ContentTypeAlias, defaultTemplateId: template.Id); - - // add complex editor - foreach (var pt in _contentType.PropertyTypes) - { - pt.DataTypeId = testDataType.Id; - } - - _contentType.AddPropertyType( - new PropertyType(_shortStringHelper, "complexTest", ValueStorageType.Ntext) - { - Alias = "complex", - Name = "Complex", - Description = string.Empty, - Mandatory = false, - SortOrder = 1, - DataTypeId = complexDataType.Id - }, - "content", - "Content"); - - // make them all validate with a regex rule that will not pass - foreach (var prop in _contentType.PropertyTypes) - { - prop.ValidationRegExp = "^donotmatch$"; - prop.ValidationRegExpMessage = "Does not match!"; - } - - var contentTypeService = Services.GetRequiredService(); - contentTypeService.Save(_contentType); - } - - private const string ContentTypeAlias = "textPage"; - private IContentType _contentType; - private readonly ContentModelBinderHelper _modelBinderHelper = new(); - - private readonly IShortStringHelper _shortStringHelper = - new DefaultShortStringHelper(new DefaultShortStringHelperConfig()); - - //// protected override void Compose() - //// { - //// base.Compose(); - //// - //// var complexEditorConfig = new NestedContentConfiguration - //// { - //// ContentTypes = new[] - //// { - //// new NestedContentConfiguration.ContentType { Alias = "feature" } - //// } - //// }; - //// var dataTypeService = new Mock(); - //// dataTypeService.Setup(x => x.GetDataType(It.IsAny())) - //// .Returns((int id) => id == ComplexDataTypeId - //// ? Mock.Of(x => x.Configuration == complexEditorConfig) - //// : Mock.Of()); - //// - //// var contentTypeService = new Mock(); - //// contentTypeService.Setup(x => x.GetAll(It.IsAny())) - //// .Returns(() => new List - //// { - //// _contentType - //// }); - //// - //// var textService = new Mock(); - //// textService.Setup(x => x.Localize("validation/invalidPattern", It.IsAny(), It.IsAny>())).Returns(() => "invalidPattern"); - //// textService.Setup(x => x.Localize("validation/invalidNull", It.IsAny(), It.IsAny>())).Returns("invalidNull"); - //// textService.Setup(x => x.Localize("validation/invalidEmpty", It.IsAny(), It.IsAny>())).Returns("invalidEmpty"); - //// - //// composition.Services.AddUnique(x => Mock.Of(x => x.GetDataType(It.IsAny()) == Mock.Of())); - //// composition.Services.AddUnique(x => dataTypeService.Object); - //// composition.Services.AddUnique(x => contentTypeService.Object); - //// composition.Services.AddUnique(x => textService.Object); - //// - //// Composition.WithCollectionBuilder() - //// .Add() - //// .Add(); - //// } - - [Test] - public void Validating_ContentItemSave() - { - var logger = Services.GetRequiredService>(); - var propertyValidationService = Services.GetRequiredService(); - var umbracoMapper = Services.GetRequiredService(); - - var validator = new ContentSaveModelValidator(logger, propertyValidationService); - - var content = ContentBuilder.CreateTextpageContent(_contentType, "test", -1); - - var id1 = new Guid("c8df5136-d606-41f0-9134-dea6ae0c2fd9"); - var id2 = new Guid("f916104a-4082-48b2-a515-5c4bf2230f38"); - var id3 = new Guid("77E15DE9-1C79-47B2-BC60-4913BC4D4C6A"); - - // TODO: Ok now test with a 4th level complex nested editor - - var complexValue = @"[{ - ""key"": """ + id1 + @""", - ""name"": ""Hello world"", - ""ncContentTypeAlias"": """ + ContentTypeAlias + @""", - ""title"": ""Hello world"", - ""bodyText"": ""The world is round"" - }, { - ""key"": """ + id2 + @""", - ""name"": ""Super nested"", - ""ncContentTypeAlias"": """ + ContentTypeAlias + @""", - ""title"": ""Hi there!"", - ""bodyText"": ""Well hello there"", - ""complex"" : [{ - ""key"": """ + id3 + @""", - ""name"": ""I am a sub nested content"", - ""ncContentTypeAlias"": """ + ContentTypeAlias + @""", - ""title"": ""Hello up there :)"", - ""bodyText"": ""Hello way up there on a different level"" - }] - } - ]"; - content.SetValue("complex", complexValue); - - // map the persisted properties to a model representing properties to save - // var saveProperties = content.Properties.Select(x => Mapper.Map(x)).ToList(); - var saveProperties = content.Properties.Select(x => - { - return new ContentPropertyBasic { Alias = x.Alias, Id = x.Id, Value = x.GetValue() }; - }).ToList(); - - var saveVariants = new List - { - new() - { - Culture = string.Empty, - Segment = string.Empty, - Name = content.Name, - Save = true, - Properties = saveProperties - } - }; - - var save = new ContentItemSave - { - Id = content.Id, - Action = ContentSaveAction.Save, - ContentTypeAlias = _contentType.Alias, - ParentId = -1, - PersistedContent = content, - TemplateAlias = null, - Variants = saveVariants - }; - - // This will map the ContentItemSave.Variants.PropertyCollectionDto and then map the values in the saved model - // back onto the persisted IContent model. - ContentItemBinder.BindModel(save, content, _modelBinderHelper, umbracoMapper); - - var modelState = new ModelStateDictionary(); - var isValid = - validator.ValidatePropertiesData(save, saveVariants[0], saveVariants[0].PropertyCollectionDto, modelState); - - // list results for debugging - foreach (var state in modelState) - { - Console.WriteLine(state.Key); - foreach (var error in state.Value.Errors) - { - Console.WriteLine("\t" + error.ErrorMessage); - } - } - - // assert - Assert.IsFalse(isValid); - Assert.AreEqual(11, modelState.Keys.Count()); - const string complexPropertyKey = "_Properties.complex.invariant.null"; - Assert.IsTrue(modelState.Keys.Contains(complexPropertyKey)); - foreach (var state in modelState.Where(x => x.Key != complexPropertyKey)) - { - foreach (var error in state.Value.Errors) - { - Assert.IsFalse(error.ErrorMessage.DetectIsJson()); // non complex is just an error message - } - } - - var complexEditorErrors = modelState.Single(x => x.Key == complexPropertyKey).Value.Errors; - Assert.AreEqual(1, complexEditorErrors.Count); - var nestedError = complexEditorErrors[0]; - var jsonError = JsonConvert.DeserializeObject(nestedError.ErrorMessage); - - string[] modelStateKeys = - { - "_Properties.title.invariant.null.innerFieldId", "_Properties.title.invariant.null.value", - "_Properties.bodyText.invariant.null.innerFieldId", "_Properties.bodyText.invariant.null.value" - }; - AssertNestedValidation(jsonError, 0, id1, modelStateKeys); - AssertNestedValidation( - jsonError, - 1, - id2, - modelStateKeys.Concat(new[] - { - "_Properties.complex.invariant.null.innerFieldId", "_Properties.complex.invariant.null.value" - }).ToArray()); - var nestedJsonError = jsonError.SelectToken("$[1].complex") as JArray; - Assert.IsNotNull(nestedJsonError); - AssertNestedValidation(nestedJsonError, 0, id3, modelStateKeys); - } - - private void AssertNestedValidation(JArray jsonError, int index, Guid id, string[] modelStateKeys) - { - Assert.IsNotNull(jsonError.SelectToken("$[" + index + "]")); - Assert.AreEqual(id.ToString(), jsonError.SelectToken("$[" + index + "].$id").Value()); - Assert.AreEqual("textPage", jsonError.SelectToken("$[" + index + "].$elementTypeAlias").Value()); - Assert.IsNotNull(jsonError.SelectToken("$[" + index + "].ModelState")); - foreach (var key in modelStateKeys) - { - var error = jsonError.SelectToken("$[" + index + "].ModelState['" + key + "']") as JArray; - Assert.IsNotNull(error); - Assert.AreEqual(1, error.Count); - } - } - - // [HideFromTypeFinder] - [DataEditor("complexTest", "test", "test")] - public class ComplexTestEditor : NestedContentPropertyEditor - { - public ComplexTestEditor( - IDataValueEditorFactory dataValueEditorFactory, - IIOHelper ioHelper, - IEditorConfigurationParser editorConfigurationParser) - : base(dataValueEditorFactory, ioHelper, editorConfigurationParser) - { - } - - protected override IDataValueEditor CreateValueEditor() - { - var editor = base.CreateValueEditor(); - editor.Validators.Add(new NeverValidateValidator()); - return editor; - } - } - - // [HideFromTypeFinder] - [DataEditor("test", "test", "test")] // This alias aligns with the prop editor alias for all properties created from MockedContentTypes.CreateTextPageContentType - public class TestEditor : DataEditor - { - public TestEditor( - IDataValueEditorFactory dataValueEditorFactory) - : base(dataValueEditorFactory) - { - } - - protected override IDataValueEditor CreateValueEditor() => - DataValueEditorFactory.Create(Attribute); - - private class TestValueEditor : DataValueEditor - { - public TestValueEditor( - ILocalizedTextService localizedTextService, - IShortStringHelper shortStringHelper, - IJsonSerializer jsonSerializer, - IIOHelper ioHelper, - DataEditorAttribute attribute) - : base(localizedTextService, shortStringHelper, jsonSerializer, ioHelper, attribute) => - Validators.Add(new NeverValidateValidator()); - } - } - - public class NeverValidateValidator : IValueValidator - { - public IEnumerable Validate(object value, string valueType, object dataTypeConfiguration) - { - yield return new ValidationResult("WRONG!", new[] { "innerFieldId" }); - } - } -} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Manifest/LegacyManifestParserTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Manifest/LegacyManifestParserTests.cs index d08953a6a3..10c69d3125 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Manifest/LegacyManifestParserTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Manifest/LegacyManifestParserTests.cs @@ -30,8 +30,8 @@ public class LegacyManifestParserTests { var validators = new IManifestValueValidator[] { - new RequiredValidator(Mock.Of()), - new RegexValidator(Mock.Of(), null), + new RequiredValidator(), + new RegexValidator(), new DelimitedValueValidator(), }; _ioHelper = TestHelper.IOHelper; diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/VariationTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/VariationTests.cs index 672427697f..e4698b9a39 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/VariationTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Models/VariationTests.cs @@ -624,14 +624,12 @@ public class VariationTests { var ioHelper = Mock.Of(); var dataTypeService = Mock.Of(); - var localizedTextService = Mock.Of(); var editorConfigurationParser = Mock.Of(); var attribute = new DataEditorAttribute("a", "a", "a"); var dataValueEditorFactory = Mock.Of(x => x.Create(It.IsAny()) == new TextOnlyValueEditor( attribute, - localizedTextService, Mock.Of(), new JsonNetSerializer(), Mock.Of())); @@ -652,7 +650,6 @@ public class VariationTests return new PropertyValidationService( propertyEditorCollection, dataTypeService, - localizedTextService, new ValueEditorCache()); } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueEditorReuseTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueEditorReuseTests.cs index f56c974f34..d7758ce05b 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueEditorReuseTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/DataValueEditorReuseTests.cs @@ -26,7 +26,6 @@ public class DataValueEditorReuseTests .Setup(m => m.Create(It.IsAny())) .Returns(() => new TextOnlyValueEditor( new DataEditorAttribute("a", "b", "c"), - Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of())); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultipleTextStringValueEditorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultipleTextStringValueEditorTests.cs index cbfdd989f3..b78a6a41af 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultipleTextStringValueEditorTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/PropertyEditors/MultipleTextStringValueEditorTests.cs @@ -130,11 +130,10 @@ public class MultipleTextStringValueEditorTests private static MultipleTextStringPropertyEditor.MultipleTextStringPropertyValueEditor CreateValueEditor() { var valueEditor = new MultipleTextStringPropertyEditor.MultipleTextStringPropertyValueEditor( - Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), new DataEditorAttribute("alias", "name", "view")); return valueEditor; } -} \ No newline at end of file +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyValidationServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyValidationServiceTests.cs index 47331a0bef..24961deaf9 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyValidationServiceTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Infrastructure/Services/PropertyValidationServiceTests.cs @@ -1,7 +1,6 @@ // Copyright (c) Umbraco. // See LICENSE for more details. -using System.Threading; using Moq; using NUnit.Framework; using Umbraco.Cms.Core; @@ -24,11 +23,6 @@ public class PropertyValidationServiceTests private void MockObjects(out PropertyValidationService validationService, out IDataType dt) { - var textService = new Mock(); - textService.Setup(x => - x.Localize(It.IsAny(), It.IsAny(), Thread.CurrentThread.CurrentCulture, null)) - .Returns("Localized text"); - var dataTypeService = new Mock(); var dataType = Mock.Of( x => x.ConfigurationObject == string.Empty // irrelevant but needs a value @@ -44,14 +38,13 @@ public class PropertyValidationServiceTests Mock.Get(dataEditor).Setup(x => x.GetValueEditor(It.IsAny())) .Returns(new CustomTextOnlyValueEditor( new DataEditorAttribute(Constants.PropertyEditors.Aliases.TextBox, "Test Textbox", "textbox"), - textService.Object, Mock.Of(), new JsonNetSerializer(), Mock.Of())); var propEditors = new PropertyEditorCollection(new DataEditorCollection(() => new[] { dataEditor })); - validationService = new PropertyValidationService(propEditors, dataTypeService.Object, Mock.Of(), new ValueEditorCache()); + validationService = new PropertyValidationService(propEditors, dataTypeService.Object, new ValueEditorCache()); } [Test] @@ -281,18 +274,17 @@ public class PropertyValidationServiceTests // in to create the Requried and Regex validators so we aren't using singletons private class CustomTextOnlyValueEditor : TextOnlyValueEditor { - private readonly ILocalizedTextService _textService; - public CustomTextOnlyValueEditor( DataEditorAttribute attribute, - ILocalizedTextService textService, IShortStringHelper shortStringHelper, IJsonSerializer jsonSerializer, IIOHelper ioHelper) - : base(attribute, textService, shortStringHelper, jsonSerializer, ioHelper) => _textService = textService; + : base(attribute, shortStringHelper, jsonSerializer, ioHelper) + { + } - public override IValueRequiredValidator RequiredValidator => new RequiredValidator(_textService); + public override IValueRequiredValidator RequiredValidator => new RequiredValidator(); - public override IValueFormatValidator FormatValidator => new RegexValidator(_textService, null); + public override IValueFormatValidator FormatValidator => new RegexValidator(); } } diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs index 4538e40a29..6d6d10d3bb 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.BackOffice/Controllers/MemberControllerUnitTests.cs @@ -553,7 +553,6 @@ public class MemberControllerUnitTests && x.Alias == Constants.PropertyEditors.Aliases.Label); Mock.Get(dataEditor).Setup(x => x.GetValueEditor()).Returns(new TextOnlyValueEditor( new DataEditorAttribute(Constants.PropertyEditors.Aliases.TextBox, "Test Textbox", "textbox"), - textService.Object, Mock.Of(), Mock.Of(), Mock.Of()));