Document version endpoints (#15946)

* Rename/Move/duplicate PaginationService to facilitate conversion closer to the data layer

Duplication is because of internal modifier as we don't want to expose these temporary classes

* Move Guid to Int Extensions into core + add unittests

* Added Document version endpoints

Updated used services to use async methods

* Moved PaginationConverter into core so it can be used by the service layer

* Endpoint structure improvements

* Updating OpenApi.json

* Add greedy constructors for contentService tests

* Namespace changes and naming cleanup

* Update openapispec again...

* Refactor injected services

* PR suggestion updates

- Move endpoints into their own structural section as they are also in a different swagger section
- Naming improvements
- Allign PresentationFactories with similar classes
- Cleanup unused assignments
- Cleanup refactoring comments
- Improve obsoletion remarks

* Cleanup

* ResponseModel improvements

* OpenApi spec update

---------

Co-authored-by: Sven Geusens <sge@umbraco.dk>
Co-authored-by: Elitsa <elm@umbraco.dk>
This commit is contained in:
Sven Geusens
2024-04-02 12:12:36 +02:00
committed by GitHub
parent 1866b61e12
commit 95849c265b
21 changed files with 1136 additions and 15 deletions

View File

@@ -0,0 +1,49 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.ViewModels.Pagination;
using Umbraco.Cms.Api.Management.Factories;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Controllers.DocumentVersion;
[ApiVersion("1.0")]
public class AllDocumentVersionController : DocumentVersionControllerBase
{
private readonly IContentVersionService _contentVersionService;
private readonly IDocumentVersionPresentationFactory _documentVersionPresentationFactory;
public AllDocumentVersionController(
IContentVersionService contentVersionService,
IDocumentVersionPresentationFactory documentVersionPresentationFactory)
{
_contentVersionService = contentVersionService;
_documentVersionPresentationFactory = documentVersionPresentationFactory;
}
[MapToApiVersion("1.0")]
[HttpGet]
[ProducesResponseType(typeof(PagedViewModel<DocumentVersionItemResponseModel>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> All([Required] Guid documentId, string? culture, int skip = 0, int take = 100)
{
Attempt<PagedModel<ContentVersionMeta>?, ContentVersionOperationStatus> attempt =
await _contentVersionService.GetPagedContentVersionsAsync(documentId, culture, skip, take);
var pagedViewModel = new PagedViewModel<DocumentVersionItemResponseModel>
{
Total = attempt.Result!.Total,
Items = await _documentVersionPresentationFactory.CreateMultipleAsync(attempt.Result!.Items),
};
return attempt.Success
? Ok(pagedViewModel)
: MapFailure(attempt.Status);
}
}

View File

@@ -0,0 +1,41 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Controllers.DocumentVersion;
[ApiVersion("1.0")]
public class ByKeyDocumentVersionController : DocumentVersionControllerBase
{
private readonly IContentVersionService _contentVersionService;
private readonly IUmbracoMapper _umbracoMapper;
public ByKeyDocumentVersionController(
IContentVersionService contentVersionService,
IUmbracoMapper umbracoMapper)
{
_contentVersionService = contentVersionService;
_umbracoMapper = umbracoMapper;
}
[MapToApiVersion("1.0")]
[HttpGet("{id:guid}")]
[ProducesResponseType(typeof(DocumentVersionResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ByKey(Guid id)
{
Attempt<IContent?, ContentVersionOperationStatus> attempt =
await _contentVersionService.GetAsync(id);
return attempt.Success
? Ok(_umbracoMapper.Map<DocumentVersionResponseModel>(attempt.Result))
: MapFailure(attempt.Status);
}
}

View File

@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.Routing;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Web.Common.Authorization;
namespace Umbraco.Cms.Api.Management.Controllers.DocumentVersion;
[VersionedApiBackOfficeRoute($"{Constants.UdiEntityType.Document}-version")]
[ApiExplorerSettings(GroupName = $"{nameof(Constants.UdiEntityType.Document)} Version")]
[Authorize(Policy = AuthorizationPolicies.TreeAccessDocuments)]
public abstract class DocumentVersionControllerBase : ManagementApiControllerBase
{
protected IActionResult MapFailure(ContentVersionOperationStatus status)
=> OperationStatusResult(status, problemDetailsBuilder => status switch
{
ContentVersionOperationStatus.NotFound => NotFound(problemDetailsBuilder
.WithTitle("The requested version could not be found")
.Build()),
ContentVersionOperationStatus.ContentNotFound => NotFound(problemDetailsBuilder
.WithTitle("The requested document could not be found")
.Build()),
ContentVersionOperationStatus.InvalidSkipTake => SkipTakeToPagingProblem(),
ContentVersionOperationStatus.RollBackFailed => BadRequest(problemDetailsBuilder
.WithTitle("Rollback failed")
.WithDetail("An unspecified error occurred while rolling back the requested version. Please check the logs for additional information.")),
ContentVersionOperationStatus.RollBackCanceled => BadRequest(problemDetailsBuilder
.WithTitle("Request cancelled by notification")
.WithDetail("The request to roll back was cancelled by a notification handler.")
.Build()),
_ => StatusCode(StatusCodes.Status500InternalServerError, problemDetailsBuilder
.WithTitle("Unknown content version operation status.")
.Build()),
});
}

View File

@@ -0,0 +1,39 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Controllers.DocumentVersion;
[ApiVersion("1.0")]
public class RollbackDocumentVersionController : DocumentVersionControllerBase
{
private readonly IContentVersionService _contentVersionService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
public RollbackDocumentVersionController(
IContentVersionService contentVersionService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_contentVersionService = contentVersionService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
[MapToApiVersion("1.0")]
[HttpPost("{id:guid}/rollback")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Rollback(Guid id, string? culture)
{
Attempt<ContentVersionOperationStatus> attempt =
await _contentVersionService.RollBackAsync(id, culture, CurrentUserKey(_backOfficeSecurityAccessor));
return attempt.Success
? Ok()
: MapFailure(attempt.Result);
}
}

View File

@@ -0,0 +1,39 @@
using Asp.Versioning;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Controllers.DocumentVersion;
[ApiVersion("1.0")]
public class UpdatePreventCleanupDocumentVersionController : DocumentVersionControllerBase
{
private readonly IContentVersionService _contentVersionService;
private readonly IBackOfficeSecurityAccessor _backOfficeSecurityAccessor;
public UpdatePreventCleanupDocumentVersionController(
IContentVersionService contentVersionService,
IBackOfficeSecurityAccessor backOfficeSecurityAccessor)
{
_contentVersionService = contentVersionService;
_backOfficeSecurityAccessor = backOfficeSecurityAccessor;
}
[MapToApiVersion("1.0")]
[HttpPut("{id:guid}/prevent-cleanup")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Set(Guid id, bool preventCleanup)
{
Attempt<ContentVersionOperationStatus> attempt =
await _contentVersionService.SetPreventCleanupAsync(id, preventCleanup, CurrentUserKey(_backOfficeSecurityAccessor));
return attempt.Success
? Ok()
: MapFailure(attempt.Result);
}
}

View File

@@ -62,4 +62,13 @@ public abstract class ManagementApiControllerBase : Controller, IUmbracoFeature
protected static IActionResult OperationStatusResult<TEnum>(TEnum status, Func<ProblemDetailsBuilder, IActionResult> result)
where TEnum : Enum
=> result(new ProblemDetailsBuilder().WithOperationStatus(status));
protected BadRequestObjectResult SkipTakeToPagingProblem() =>
BadRequest(new ProblemDetails
{
Title = "Invalid skip/take",
Detail = "Skip must be a multiple of take - i.e. skip = 10, take = 5",
Status = StatusCodes.Status400BadRequest,
Type = "Error",
});
}

View File

@@ -16,10 +16,12 @@ internal static class DocumentBuilderExtensions
builder.Services.AddTransient<IDocumentEditingPresentationFactory, DocumentEditingPresentationFactory>();
builder.Services.AddTransient<IPublicAccessPresentationFactory, PublicAccessPresentationFactory>();
builder.Services.AddTransient<IDomainPresentationFactory, DomainPresentationFactory>();
builder.Services.AddTransient<IDocumentVersionPresentationFactory, DocumentVersionPresentationFactory>();
builder.WithCollectionBuilder<MapDefinitionCollectionBuilder>()
.Add<DocumentMapDefinition>()
.Add<DomainMapDefinition>();
.Add<DomainMapDefinition>()
.Add<DocumentVersionMapDefinition>();
return builder;
}

View File

@@ -0,0 +1,36 @@
using Umbraco.Cms.Api.Management.ViewModels;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Factories;
internal sealed class DocumentVersionPresentationFactory : IDocumentVersionPresentationFactory
{
private readonly IEntityService _entityService;
private readonly IUserIdKeyResolver _userIdKeyResolver;
public DocumentVersionPresentationFactory(
IEntityService entityService,
IUserIdKeyResolver userIdKeyResolver)
{
_entityService = entityService;
_userIdKeyResolver = userIdKeyResolver;
}
public async Task<DocumentVersionItemResponseModel> CreateAsync(ContentVersionMeta contentVersion) =>
new(
contentVersion.VersionId.ToGuid(), // this is a magic guid since versions do not have keys in the DB
new ReferenceByIdModel(_entityService.GetKey(contentVersion.ContentId, UmbracoObjectTypes.Document).Result),
new ReferenceByIdModel(_entityService.GetKey(contentVersion.ContentTypeId, UmbracoObjectTypes.DocumentType)
.Result),
new ReferenceByIdModel(await _userIdKeyResolver.GetAsync(contentVersion.UserId)),
new DateTimeOffset(contentVersion.VersionDate, TimeSpan.Zero), // todo align with datetime offset rework
contentVersion.CurrentPublishedVersion,
contentVersion.CurrentDraftVersion,
contentVersion.PreventCleanup);
public async Task<IEnumerable<DocumentVersionItemResponseModel>> CreateMultipleAsync(IEnumerable<ContentVersionMeta> contentVersions) =>
await Task.WhenAll(contentVersions.Select(CreateAsync));
}

View File

@@ -0,0 +1,12 @@
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Api.Management.Factories;
public interface IDocumentVersionPresentationFactory
{
Task<DocumentVersionItemResponseModel> CreateAsync(ContentVersionMeta contentVersion);
Task<IEnumerable<DocumentVersionItemResponseModel>> CreateMultipleAsync(
IEnumerable<ContentVersionMeta> contentVersions);
}

View File

@@ -0,0 +1,40 @@
using Umbraco.Cms.Api.Management.Mapping.Content;
using Umbraco.Cms.Api.Management.ViewModels;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Api.Management.ViewModels.DocumentType;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PropertyEditors;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Mapping.Document;
public class DocumentVersionMapDefinition : ContentMapDefinition<IContent, DocumentValueModel, DocumentVariantResponseModel>, IMapDefinition
{
public DocumentVersionMapDefinition(PropertyEditorCollection propertyEditorCollection)
: base(propertyEditorCollection)
{
}
public void DefineMaps(IUmbracoMapper mapper)
{
mapper.Define<IContent, DocumentVersionResponseModel>((_, _) => new DocumentVersionResponseModel(), Map);
}
private void Map(IContent source, DocumentVersionResponseModel target, MapperContext context)
{
target.Id = source.VersionId.ToGuid(); // this is a magic guid since versions do not have Guids in the DB
target.Document = new ReferenceByIdModel(source.Key);
target.DocumentType = context.Map<DocumentTypeReferenceResponseModel>(source.ContentType)!;
target.Values = MapValueViewModels(source.Properties);
target.Variants = MapVariantViewModels(
source,
(culture, _, documentVariantViewModel) =>
{
documentVariantViewModel.State = DocumentVariantStateHelper.GetState(source, culture);
documentVariantViewModel.PublishDate = culture == null
? source.PublishDate
: source.GetPublishDate(culture);
});
}
}

View File

@@ -5446,6 +5446,451 @@
]
}
},
"/umbraco/management/api/v1/document-version": {
"get": {
"tags": [
"Document Version"
],
"operationId": "GetDocumentVersion",
"parameters": [
{
"name": "documentId",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "culture",
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "skip",
"in": "query",
"schema": {
"type": "integer",
"format": "int32",
"default": 0
}
},
{
"name": "take",
"in": "query",
"schema": {
"type": "integer",
"format": "int32",
"default": 100
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PagedDocumentVersionItemResponseModel"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/PagedDocumentVersionItemResponseModel"
}
},
"text/plain": {
"schema": {
"$ref": "#/components/schemas/PagedDocumentVersionItemResponseModel"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
}
},
"security": [
{
"Backoffice User": [ ]
}
]
}
},
"/umbraco/management/api/v1/document-version/{id}": {
"get": {
"tags": [
"Document Version"
],
"operationId": "GetDocumentVersionById",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/DocumentVersionResponseModel"
}
]
}
},
"text/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/DocumentVersionResponseModel"
}
]
}
},
"text/plain": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/DocumentVersionResponseModel"
}
]
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
}
},
"security": [
{
"Backoffice User": [ ]
}
]
}
},
"/umbraco/management/api/v1/document-version/{id}/prevent-cleanup": {
"put": {
"tags": [
"Document Version"
],
"operationId": "PutDocumentVersionByIdPreventCleanup",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "preventCleanup",
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "Success",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
},
"404": {
"description": "Not Found",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"400": {
"description": "Bad Request",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
}
},
"security": [
{
"Backoffice User": [ ]
}
]
}
},
"/umbraco/management/api/v1/document-version/{id}/rollback": {
"post": {
"tags": [
"Document Version"
],
"operationId": "PostDocumentVersionByIdRollback",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "culture",
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
}
},
"404": {
"description": "Not Found",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"400": {
"description": "Bad Request",
"headers": {
"Umb-Notifications": {
"description": "The list of notifications produced during the request.",
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/NotificationHeaderModel"
},
"nullable": true
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
},
"text/plain": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"401": {
"description": "The resource is protected and requires an authentication token"
}
},
"security": [
{
"Backoffice User": [ ]
}
]
}
},
"/umbraco/management/api/v1/collection/document/{id}": {
"get": {
"tags": [
@@ -36214,6 +36659,89 @@
],
"type": "string"
},
"DocumentVersionItemResponseModel": {
"required": [
"document",
"documentType",
"id",
"isCurrentDraftVersion",
"isCurrentPublishedVersion",
"preventCleanup",
"user",
"versionDate"
],
"type": "object",
"properties": {
"id": {
"type": "string",
"format": "uuid"
},
"document": {
"oneOf": [
{
"$ref": "#/components/schemas/ReferenceByIdModel"
}
]
},
"documentType": {
"oneOf": [
{
"$ref": "#/components/schemas/ReferenceByIdModel"
}
]
},
"user": {
"oneOf": [
{
"$ref": "#/components/schemas/ReferenceByIdModel"
}
]
},
"versionDate": {
"type": "string",
"format": "date-time"
},
"isCurrentPublishedVersion": {
"type": "boolean"
},
"isCurrentDraftVersion": {
"type": "boolean"
},
"preventCleanup": {
"type": "boolean"
}
},
"additionalProperties": false
},
"DocumentVersionResponseModel": {
"required": [
"documentType"
],
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/ContentForDocumentResponseModel"
}
],
"properties": {
"document": {
"oneOf": [
{
"$ref": "#/components/schemas/ReferenceByIdModel"
}
],
"nullable": true
},
"documentType": {
"oneOf": [
{
"$ref": "#/components/schemas/DocumentTypeReferenceResponseModel"
}
]
}
},
"additionalProperties": false
},
"DomainPresentationModel": {
"required": [
"domainName",
@@ -38891,6 +39419,30 @@
},
"additionalProperties": false
},
"PagedDocumentVersionItemResponseModel": {
"required": [
"items",
"total"
],
"type": "object",
"properties": {
"total": {
"type": "integer",
"format": "int64"
},
"items": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/DocumentVersionItemResponseModel"
}
]
}
}
},
"additionalProperties": false
},
"PagedFileSystemTreeItemPresentationModel": {
"required": [
"items",
@@ -43030,4 +43582,4 @@
}
}
}
}
}

View File

@@ -0,0 +1,41 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Document;
public class DocumentVersionItemResponseModel
{
public DocumentVersionItemResponseModel(
Guid id,
ReferenceByIdModel document,
ReferenceByIdModel documentType,
ReferenceByIdModel user,
DateTimeOffset versionDate,
bool isCurrentPublishedVersion,
bool isCurrentDraftVersion,
bool preventCleanup)
{
Id = id;
Document = document;
DocumentType = documentType;
User = user;
VersionDate = versionDate;
IsCurrentPublishedVersion = isCurrentPublishedVersion;
IsCurrentDraftVersion = isCurrentDraftVersion;
PreventCleanup = preventCleanup;
}
public Guid Id { get; }
public ReferenceByIdModel Document { get; }
public ReferenceByIdModel DocumentType { get; }
public ReferenceByIdModel User { get; }
public DateTimeOffset VersionDate { get; }
public bool IsCurrentPublishedVersion { get; }
public bool IsCurrentDraftVersion { get; }
public bool PreventCleanup { get; }
}

View File

@@ -0,0 +1,11 @@
using Umbraco.Cms.Api.Management.ViewModels.Content;
using Umbraco.Cms.Api.Management.ViewModels.DocumentType;
namespace Umbraco.Cms.Api.Management.ViewModels.Document;
public class DocumentVersionResponseModel : ContentResponseModelBase<DocumentValueModel, DocumentVariantResponseModel>
{
public ReferenceByIdModel? Document { get; set; }
public DocumentTypeReferenceResponseModel DocumentType { get; set; } = new();
}

View File

@@ -1,22 +1,17 @@
namespace Umbraco.Cms.Infrastructure.Extensions;
namespace Umbraco.Cms.Core.Extensions;
public static class GuidExtensions
{
internal static bool IsFakeGuid(this Guid guid)
public static bool IsFakeGuid(this Guid guid)
{
var bytes = guid.ToByteArray();
// Our fake guid is a 32 bit int, converted to a byte representation,
// so we can check if everything but the first 4 bytes are 0, if so, we know it's a fake guid.
if (bytes[4..].All(x => x == 0))
{
return true;
}
return false;
return bytes[4..].All(x => x == 0);
}
internal static int ToInt(this Guid guid)
public static int ToInt(this Guid guid)
{
var bytes = guid.ToByteArray();
return BitConverter.ToInt32(bytes, 0);

View File

@@ -1,9 +1,15 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Extensions;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.Entities;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Core.Services.Pagination;
using Umbraco.Extensions;
// ReSharper disable once CheckNamespace
@@ -16,9 +22,37 @@ internal class ContentVersionService : IContentVersionService
private readonly IDocumentVersionRepository _documentVersionRepository;
private readonly IEventMessagesFactory _eventMessagesFactory;
private readonly ILanguageRepository _languageRepository;
private readonly IEntityService _entityService;
private readonly IContentService _contentService;
private readonly IUserIdKeyResolver _userIdKeyResolver;
private readonly ILogger<ContentVersionService> _logger;
private readonly ICoreScopeProvider _scopeProvider;
public ContentVersionService(
ILogger<ContentVersionService> logger,
IDocumentVersionRepository documentVersionRepository,
IContentVersionCleanupPolicy contentVersionCleanupPolicy,
ICoreScopeProvider scopeProvider,
IEventMessagesFactory eventMessagesFactory,
IAuditRepository auditRepository,
ILanguageRepository languageRepository,
IEntityService entityService,
IContentService contentService,
IUserIdKeyResolver userIdKeyResolver)
{
_logger = logger;
_documentVersionRepository = documentVersionRepository;
_contentVersionCleanupPolicy = contentVersionCleanupPolicy;
_scopeProvider = scopeProvider;
_eventMessagesFactory = eventMessagesFactory;
_auditRepository = auditRepository;
_languageRepository = languageRepository;
_entityService = entityService;
_contentService = contentService;
_userIdKeyResolver = userIdKeyResolver;
}
[Obsolete("Use the non obsolete constructor instead. Scheduled to be removed in v15")]
public ContentVersionService(
ILogger<ContentVersionService> logger,
IDocumentVersionRepository documentVersionRepository,
@@ -35,6 +69,9 @@ internal class ContentVersionService : IContentVersionService
_eventMessagesFactory = eventMessagesFactory;
_auditRepository = auditRepository;
_languageRepository = languageRepository;
_entityService = StaticServiceProvider.Instance.GetRequiredService<IEntityService>();
_contentService = StaticServiceProvider.Instance.GetRequiredService<IContentService>();
_userIdKeyResolver = StaticServiceProvider.Instance.GetRequiredService<IUserIdKeyResolver>();
}
/// <inheritdoc />
@@ -45,6 +82,7 @@ internal class ContentVersionService : IContentVersionService
CleanupDocumentVersions(asAtDate);
/// <inheritdoc />
[Obsolete("Use the async version instead. Scheduled to be removed in v15")]
public IEnumerable<ContentVersionMeta>? GetPagedContentVersions(int contentId, long pageIndex, int pageSize, out long totalRecords, string? culture = null)
{
if (pageIndex < 0)
@@ -57,6 +95,113 @@ internal class ContentVersionService : IContentVersionService
throw new ArgumentOutOfRangeException(nameof(pageSize));
}
return HandleGetPagedContentVersions(contentId, pageIndex, pageSize, out totalRecords, culture);
}
public ContentVersionMeta? Get(int versionId)
{
using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true))
{
scope.ReadLock(Constants.Locks.ContentTree);
return _documentVersionRepository.Get(versionId);
}
}
/// <inheritdoc />
[Obsolete("Use the async version instead. Scheduled to be removed in v15")]
public void SetPreventCleanup(int versionId, bool preventCleanup, int userId = Constants.Security.SuperUserId)
=> HandleSetPreventCleanup(versionId, preventCleanup, userId);
public async Task<Attempt<PagedModel<ContentVersionMeta>?, ContentVersionOperationStatus>> GetPagedContentVersionsAsync(Guid contentId, string? culture, int skip, int take)
{
IEntitySlim? document = await Task.FromResult(_entityService.Get(contentId, UmbracoObjectTypes.Document));
if (document is null)
{
return Attempt<PagedModel<ContentVersionMeta>?, ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.ContentNotFound);
}
if (PaginationConverter.ConvertSkipTakeToPaging(skip, take, out var pageNumber, out var pageSize) == false)
{
return Attempt<PagedModel<ContentVersionMeta>?, ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.InvalidSkipTake);
}
IEnumerable<ContentVersionMeta>? versions =
await Task.FromResult(HandleGetPagedContentVersions(
document.Id,
pageNumber,
pageSize,
out var total,
culture));
if (versions is null)
{
return Attempt<PagedModel<ContentVersionMeta>?, ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.NotFound);
}
return Attempt<PagedModel<ContentVersionMeta>?, ContentVersionOperationStatus>.Succeed(
ContentVersionOperationStatus.Success, new PagedModel<ContentVersionMeta>(total, versions));
}
public async Task<Attempt<IContent?, ContentVersionOperationStatus>> GetAsync(Guid versionId)
{
IContent? version = await Task.FromResult(_contentService.GetVersion(versionId.ToInt()));
if (version is null)
{
return Attempt<IContent?, ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus
.NotFound);
}
return Attempt<IContent?, ContentVersionOperationStatus>.Succeed(ContentVersionOperationStatus.Success, version);
}
public async Task<Attempt<ContentVersionOperationStatus>> SetPreventCleanupAsync(Guid versionId, bool preventCleanup, Guid userKey)
{
ContentVersionMeta? version = Get(versionId.ToInt());
if (version is null)
{
return Attempt<ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.NotFound);
}
HandleSetPreventCleanup(version.VersionId, preventCleanup, await _userIdKeyResolver.GetAsync(userKey));
return Attempt<ContentVersionOperationStatus>.Succeed(ContentVersionOperationStatus.Success);
}
public async Task<Attempt<ContentVersionOperationStatus>> RollBackAsync(Guid versionId, string? culture, Guid userKey)
{
ContentVersionMeta? version = Get(versionId.ToInt());
if (version is null)
{
return Attempt<ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.NotFound);
}
OperationResult rollBackResult = _contentService.Rollback(
version.ContentId,
version.VersionId,
culture ?? "*",
await _userIdKeyResolver.GetAsync(userKey));
if (rollBackResult.Success)
{
return Attempt<ContentVersionOperationStatus>.Succeed(ContentVersionOperationStatus.Success);
}
switch (rollBackResult.Result)
{
case OperationResultType.Failed:
case OperationResultType.FailedCannot:
case OperationResultType.FailedExceptionThrown:
case OperationResultType.NoOperation:
default:
return Attempt<ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.RollBackFailed);
case OperationResultType.FailedCancelledByEvent:
return Attempt<ContentVersionOperationStatus>.Fail(ContentVersionOperationStatus.RollBackCanceled);
}
}
private IEnumerable<ContentVersionMeta>? HandleGetPagedContentVersions(int contentId, long pageIndex,
int pageSize, out long totalRecords, string? culture = null)
{
using (ICoreScope scope = _scopeProvider.CreateCoreScope(autoComplete: true))
{
var languageId = _languageRepository.GetIdByIsoCode(culture, true);
@@ -65,8 +210,7 @@ internal class ContentVersionService : IContentVersionService
}
}
/// <inheritdoc />
public void SetPreventCleanup(int versionId, bool preventCleanup, int userId = Constants.Security.SuperUserId)
private void HandleSetPreventCleanup(int versionId, bool preventCleanup, int userId)
{
using (ICoreScope scope = _scopeProvider.CreateCoreScope())
{

View File

@@ -1,4 +1,5 @@
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
@@ -13,10 +14,19 @@ public interface IContentVersionService
/// Gets paginated content versions for given content id paginated.
/// </summary>
/// <exception cref="ArgumentException">Thrown when <paramref name="culture" /> is invalid.</exception>
[Obsolete("Use the async version instead. Scheduled to be removed in v15")]
IEnumerable<ContentVersionMeta>? GetPagedContentVersions(int contentId, long pageIndex, int pageSize, out long totalRecords, string? culture = null);
/// <summary>
/// Updates preventCleanup value for given content version.
/// </summary>
[Obsolete("Use the async version instead. Scheduled to be removed in v15")]
void SetPreventCleanup(int versionId, bool preventCleanup, int userId = -1);
ContentVersionMeta? Get(int versionId);
Task<Attempt<PagedModel<ContentVersionMeta>?, ContentVersionOperationStatus>> GetPagedContentVersionsAsync(Guid contentId, string? culture, int skip, int take);
Task<Attempt<IContent?, ContentVersionOperationStatus>> GetAsync(Guid versionId);
Task<Attempt<ContentVersionOperationStatus>> SetPreventCleanupAsync(Guid versionId, bool preventCleanup, Guid userKey);
Task<Attempt<ContentVersionOperationStatus>> RollBackAsync(Guid versionId, string? culture, Guid userKey);
}

View File

@@ -0,0 +1,11 @@
namespace Umbraco.Cms.Core.Services.OperationStatus;
public enum ContentVersionOperationStatus
{
Success,
NotFound,
ContentNotFound,
InvalidSkipTake,
RollBackFailed,
RollBackCanceled
}

View File

@@ -0,0 +1,23 @@
namespace Umbraco.Cms.Core.Services.Pagination;
internal static class PaginationConverter
{
internal static bool ConvertSkipTakeToPaging(int skip, int take, out long pageNumber, out int pageSize)
{
if (take < 0)
{
throw new ArgumentException("Must be equal to or greater than zero", nameof(take));
}
if (take != 0 && skip % take != 0)
{
pageSize = 0;
pageNumber = 0;
return false;
}
pageSize = take;
pageNumber = take == 0 ? 0 : skip / take;
return true;
}
}

View File

@@ -2,7 +2,7 @@ using System.ComponentModel;
using System.Globalization;
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Umbraco.Cms.Infrastructure.Extensions;
using Umbraco.Cms.Core.Extensions;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Security;

View File

@@ -33,7 +33,8 @@ internal class UmbracoCustomizations : ICustomization
.Customize(new ConstructorCustomization(typeof(MemberManager), new GreedyConstructorQuery()))
.Customize(new ConstructorCustomization(typeof(DatabaseSchemaCreatorFactory), new GreedyConstructorQuery()))
.Customize(new ConstructorCustomization(typeof(InstallHelper), new GreedyConstructorQuery()))
.Customize(new ConstructorCustomization(typeof(DatabaseBuilder), new GreedyConstructorQuery()));
.Customize(new ConstructorCustomization(typeof(DatabaseBuilder), new GreedyConstructorQuery()))
.Customize(new ConstructorCustomization(typeof(ContentVersionService), new GreedyConstructorQuery()));
// When requesting an IUserStore ensure we actually uses a IUserLockoutStore
fixture.Customize<IUserStore<BackOfficeIdentityUser>>(cc =>

View File

@@ -0,0 +1,29 @@
using NUnit.Framework;
using Umbraco.Cms.Core.Extensions;
using Umbraco.Extensions;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Extensions;
[TestFixture]
public class IntGuidIntConversionTests
{
[TestCase(int.MinValue)]
[TestCase(int.MaxValue)]
[TestCase(int.MinValue / 2)]
[TestCase(int.MinValue / 5)]
[TestCase(int.MinValue / 707)]
[TestCase(int.MinValue / 1313)]
[TestCase(int.MinValue / 17017)]
[TestCase(int.MaxValue / 2)]
[TestCase(int.MaxValue / 5)]
[TestCase(int.MaxValue / 707)]
[TestCase(int.MaxValue / 1313)]
[TestCase(int.MaxValue / 17017)]
public void IntoToGuidToInt_NoChange(int startValue)
{
var intermediateValue = startValue.ToGuid();
var endValue = intermediateValue.ToInt();
Assert.AreEqual(startValue, endValue);
}
}