Domains and hosts API (#13963)

* API for domains and hostnames incl. unit tests

* Update Open API json

* Update other unit tests to use new domain service methods where applicable

* Fix merge + update models to new naming scheme

* Handle attempts to add the same domain twice + unit tests for duplicate domain handling

* Review fixes
This commit is contained in:
Kenn Jacobsen
2023-03-15 10:28:23 +01:00
committed by GitHub
parent ea1402de52
commit 4fb011e0fc
19 changed files with 862 additions and 62 deletions

View File

@@ -0,0 +1,30 @@
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
public class DomainsController : DocumentControllerBase
{
private readonly IDomainService _domainService;
private readonly IUmbracoMapper _umbracoMapper;
public DomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper)
{
_domainService = domainService;
_umbracoMapper = umbracoMapper;
}
[HttpGet("{key:guid}/domains")]
public async Task<IActionResult> DomainsAsync(Guid key)
{
IDomain[] assignedDomains = (await _domainService.GetAssignedDomainsAsync(key, true))
.OrderBy(d => d.SortOrder)
.ToArray();
DomainsResponseModel responseModel = _umbracoMapper.Map<DomainsResponseModel>(assignedDomains)!;
return Ok(responseModel);
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Api.Common.Builders;
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.Models.ContentEditing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Api.Management.Controllers.Document;
public class UpdateDomainsController : DocumentControllerBase
{
private readonly IDomainService _domainService;
private readonly IUmbracoMapper _umbracoMapper;
public UpdateDomainsController(IDomainService domainService, IUmbracoMapper umbracoMapper)
{
_domainService = domainService;
_umbracoMapper = umbracoMapper;
}
[HttpPut("{key:guid}/domains")]
public async Task<IActionResult> UpdateDomainsAsync(Guid key, UpdateDomainsRequestModel updateModel)
{
DomainsUpdateModel domainsUpdateModel = _umbracoMapper.Map<DomainsUpdateModel>(updateModel)!;
Attempt<IEnumerable<IDomain>, DomainOperationStatus> result = await _domainService.UpdateDomainsAsync(key, domainsUpdateModel);
return result.Status switch
{
DomainOperationStatus.Success => Ok(),
DomainOperationStatus.CancelledByNotification => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Cancelled by notification")
.WithDetail("A notification handler prevented the domain update operation.")
.Build()),
DomainOperationStatus.ContentNotFound => NotFound("The targeted content item was not found."),
DomainOperationStatus.LanguageNotFound => BadRequest(new ProblemDetailsBuilder()
.WithTitle("Invalid language specified")
.WithDetail("One or more of the specified language ISO codes could not be found.")
.Build()),
DomainOperationStatus.DuplicateDomainName => Conflict(new ProblemDetailsBuilder()
.WithTitle("Duplicate domain name detected")
.WithDetail("One or more of the specified domain names were duplicates, possibly of assignments to other content items.")
.Build()),
_ => StatusCode(StatusCodes.Status500InternalServerError, "Unknown domain update operation status")
};
}
}

View File

@@ -14,7 +14,9 @@ internal static class DocumentBuilderExtensions
builder.Services.AddTransient<IContentUrlFactory, ContentUrlFactory>();
builder.Services.AddTransient<IDocumentEditingPresentationFactory, DocumentEditingPresentationFactory>();
builder.WithCollectionBuilder<MapDefinitionCollectionBuilder>().Add<DocumentMapDefinition>();
builder.WithCollectionBuilder<MapDefinitionCollectionBuilder>()
.Add<DocumentMapDefinition>()
.Add<DomainMapDefinition>();
return builder;
}

View File

@@ -0,0 +1,40 @@
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core.Mapping;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using CoreDomainModel = Umbraco.Cms.Core.Models.ContentEditing.DomainModel;
namespace Umbraco.Cms.Api.Management.Mapping.Document;
public class DomainMapDefinition : IMapDefinition
{
public void DefineMaps(IUmbracoMapper mapper)
{
mapper.Define<IEnumerable<IDomain>, DomainsResponseModel>((_, _) => new DomainsResponseModel { Domains = Enumerable.Empty<DomainPresentationModel>() }, Map);
mapper.Define<UpdateDomainsRequestModel, DomainsUpdateModel>((_, _) => new DomainsUpdateModel { Domains = Enumerable.Empty<CoreDomainModel>() }, Map);
}
// Umbraco.Code.MapAll
private void Map(IEnumerable<IDomain> source, DomainsResponseModel target, MapperContext context)
{
IDomain[] sourceAsArray = source.ToArray();
IDomain[] wildcardsDomains = sourceAsArray.Where(d => d.IsWildcard).ToArray();
target.DefaultIsoCode = wildcardsDomains.FirstOrDefault()?.LanguageIsoCode;
target.Domains = sourceAsArray.Except(wildcardsDomains).Select(domain => new DomainPresentationModel
{
DomainName = domain.DomainName,
IsoCode = domain.LanguageIsoCode ?? string.Empty
}).ToArray();
}
private void Map(UpdateDomainsRequestModel source, DomainsUpdateModel target, MapperContext context)
{
target.DefaultIsoCode = source.DefaultIsoCode;
target.Domains = source.Domains.Select(domain => new Core.Models.ContentEditing.DomainModel
{
DomainName = domain.DomainName,
IsoCode = domain.IsoCode
}).ToArray();
}
}

View File

@@ -1745,6 +1745,65 @@
}
}
},
"/umbraco/management/api/v1/document/{key}/domains": {
"get": {
"tags": [
"Document"
],
"operationId": "GetDocumentByKeyDomains",
"parameters": [
{
"name": "key",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"responses": {
"200": {
"description": "Success"
}
}
},
"put": {
"tags": [
"Document"
],
"operationId": "PutDocumentByKeyDomains",
"parameters": [
{
"name": "key",
"in": "path",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/UpdateDomainsRequestModel"
}
]
}
}
}
},
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/umbraco/management/api/v1/recycle-bin/document/children": {
"get": {
"tags": [
@@ -7775,6 +7834,47 @@
}
}
},
"DomainPresentationModel": {
"type": "object",
"properties": {
"domainName": {
"type": "string"
},
"isoCode": {
"type": "string"
}
},
"additionalProperties": false
},
"DomainsPresentationModelBaseModel": {
"type": "object",
"properties": {
"defaultIsoCode": {
"type": "string",
"nullable": true
},
"domains": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/components/schemas/DomainPresentationModel"
}
]
}
}
},
"additionalProperties": false
},
"DomainsResponseModel": {
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/DomainsPresentationModelBaseModel"
}
],
"additionalProperties": false
},
"EntityTreeItemResponseModel": {
"required": [
"$type"
@@ -10307,6 +10407,15 @@
},
"additionalProperties": false
},
"UpdateDomainsRequestModel": {
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/DomainsPresentationModelBaseModel"
}
],
"additionalProperties": false
},
"UpdateFolderReponseModel": {
"type": "object",
"allOf": [

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Document;
public class DomainPresentationModel
{
public required string DomainName { get; set; }
public required string IsoCode { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Document;
public abstract class DomainsPresentationModelBase
{
public string? DefaultIsoCode { get; set; }
public required IEnumerable<DomainPresentationModel> Domains { get; set; }
}

View File

@@ -0,0 +1,5 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Document;
public class DomainsResponseModel : DomainsPresentationModelBase
{
}

View File

@@ -0,0 +1,5 @@
namespace Umbraco.Cms.Api.Management.ViewModels.Document;
public class UpdateDomainsRequestModel : DomainsPresentationModelBase
{
}

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Core.Models.ContentEditing;
public class DomainModel
{
public required string DomainName { get; set; }
public required string IsoCode { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace Umbraco.Cms.Core.Models.ContentEditing;
public class DomainsUpdateModel
{
public string? DefaultIsoCode { get; set; }
public required IEnumerable<DomainModel> Domains { get; set; }
}

View File

@@ -12,4 +12,9 @@ public class DomainDeletedNotification : DeletedNotification<IDomain>
: base(target, messages)
{
}
public DomainDeletedNotification(IEnumerable<IDomain> target, EventMessages messages)
: base(target, messages)
{
}
}

View File

@@ -1,23 +1,52 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.Scoping;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Services;
public class DomainService : RepositoryService, IDomainService
{
private readonly IDomainRepository _domainRepository;
private readonly ILanguageService _languageService;
private readonly IContentService _contentService;
[Obsolete("Please use the constructor that accepts ILanguageService and IContentService. Will be removed in V15.")]
public DomainService(
ICoreScopeProvider provider,
ILoggerFactory loggerFactory,
IEventMessagesFactory eventMessagesFactory,
IDomainRepository domainRepository)
: this(
provider,
loggerFactory,
eventMessagesFactory,
domainRepository,
StaticServiceProvider.Instance.GetRequiredService<ILanguageService>(),
StaticServiceProvider.Instance.GetRequiredService<IContentService>())
{
}
public DomainService(
ICoreScopeProvider provider,
ILoggerFactory loggerFactory,
IEventMessagesFactory eventMessagesFactory,
IDomainRepository domainRepository,
ILanguageService languageService,
IContentService contentService)
: base(provider, loggerFactory, eventMessagesFactory)
=> _domainRepository = domainRepository;
{
_domainRepository = domainRepository;
_languageService = languageService;
_contentService = contentService;
}
public bool Exists(string domainName)
{
@@ -27,26 +56,16 @@ public class DomainService : RepositoryService, IDomainService
}
}
[Obsolete($"Please use {nameof(UpdateDomainsAsync)}. Will be removed in V15")]
public Attempt<OperationResult?> Delete(IDomain domain)
{
EventMessages eventMessages = EventMessagesFactory.Get();
using ICoreScope scope = ScopeProvider.CreateCoreScope();
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
var deletingNotification = new DomainDeletingNotification(domain, eventMessages);
if (scope.Notifications.PublishCancelable(deletingNotification))
{
scope.Complete();
return OperationResult.Attempt.Cancel(eventMessages);
}
var result = DeleteAll(new[] { domain }, scope, eventMessages);
scope.Complete();
_domainRepository.Delete(domain);
scope.Complete();
scope.Notifications.Publish(new DomainDeletedNotification(domain, eventMessages).WithStateFrom(deletingNotification));
}
return OperationResult.Attempt.Succeed(eventMessages);
return result ? OperationResult.Attempt.Succeed(eventMessages) : OperationResult.Attempt.Cancel(eventMessages);
}
public IDomain? GetByName(string name)
@@ -65,6 +84,7 @@ public class DomainService : RepositoryService, IDomainService
}
}
[Obsolete($"Please use {nameof(GetAllAsync)}. Will be removed in V15")]
public IEnumerable<IDomain> GetAll(bool includeWildcards)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
@@ -73,6 +93,7 @@ public class DomainService : RepositoryService, IDomainService
}
}
[Obsolete($"Please use {nameof(GetAssignedDomainsAsync)}. Will be removed in V15")]
public IEnumerable<IDomain> GetAssignedDomains(int contentId, bool includeWildcards)
{
using (ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true))
@@ -81,28 +102,19 @@ public class DomainService : RepositoryService, IDomainService
}
}
[Obsolete($"Please use {nameof(UpdateDomainsAsync)}. Will be removed in V15")]
public Attempt<OperationResult?> Save(IDomain domainEntity)
{
EventMessages eventMessages = EventMessagesFactory.Get();
using ICoreScope scope = ScopeProvider.CreateCoreScope();
using (ICoreScope scope = ScopeProvider.CreateCoreScope())
{
var savingNotification = new DomainSavingNotification(domainEntity, eventMessages);
if (scope.Notifications.PublishCancelable(savingNotification))
{
scope.Complete();
return OperationResult.Attempt.Cancel(eventMessages);
}
var result = SaveAll(new[] { domainEntity }, scope, eventMessages);
scope.Complete();
_domainRepository.Save(domainEntity);
scope.Complete();
scope.Notifications.Publish(new DomainSavedNotification(domainEntity, eventMessages).WithStateFrom(savingNotification));
}
return OperationResult.Attempt.Succeed(eventMessages);
return result ? OperationResult.Attempt.Succeed(eventMessages) : OperationResult.Attempt.Cancel(eventMessages);
}
[Obsolete($"Please use {nameof(UpdateDomainsAsync)}. Will be removed in V15")]
public Attempt<OperationResult?> Sort(IEnumerable<IDomain> items)
{
EventMessages eventMessages = EventMessagesFactory.Get();
@@ -144,4 +156,205 @@ public class DomainService : RepositoryService, IDomainService
return OperationResult.Attempt.Succeed(eventMessages);
}
/// <inheritdoc />
public async Task<IEnumerable<IDomain>> GetAssignedDomainsAsync(Guid contentKey, bool includeWildcards)
{
IContent? content = _contentService.GetById(contentKey);
if (content == null)
{
return await Task.FromResult(Enumerable.Empty<IDomain>());
}
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
return _domainRepository.GetAssignedDomains(content.Id, includeWildcards);
}
/// <inheritdoc />
public async Task<IEnumerable<IDomain>> GetAllAsync(bool includeWildcards)
{
using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
return await Task.FromResult(_domainRepository.GetAll(includeWildcards));
}
/// <inheritdoc />
public async Task<Attempt<IEnumerable<IDomain>, DomainOperationStatus>> UpdateDomainsAsync(Guid contentKey, DomainsUpdateModel updateModel)
{
IContent? content = _contentService.GetById(contentKey);
if (content == null)
{
return Attempt.FailWithStatus(DomainOperationStatus.ContentNotFound, Enumerable.Empty<IDomain>());
}
using ICoreScope scope = ScopeProvider.CreateCoreScope();
IEnumerable<ILanguage> allLanguages = await _languageService.GetAllAsync();
var languageIdByIsoCode = allLanguages.ToDictionary(l => l.IsoCode, l => l.Id);
// validate language ISO codes
if (HasInvalidIsoCode(updateModel, languageIdByIsoCode.Keys))
{
return Attempt.FailWithStatus(DomainOperationStatus.LanguageNotFound, Enumerable.Empty<IDomain>());
}
// ensure all domain names in the update model are lowercased
foreach (DomainModel domainModel in updateModel.Domains)
{
domainModel.DomainName = domainModel.DomainName.ToLowerInvariant();
}
// make sure we're not attempting to assign duplicate domains
if (updateModel.Domains.GroupBy(domain => domain.DomainName).Any(group => group.Count() > 1))
{
return Attempt.FailWithStatus(DomainOperationStatus.DuplicateDomainName, Enumerable.Empty<IDomain>());
}
// grab all current domain assignments
IDomain[] allDomains = (await GetAllAsync(true)).ToArray();
// validate the domain names in the update model
if (HasDomainNameConflicts(content.Id, updateModel, allDomains))
{
return Attempt.FailWithStatus(DomainOperationStatus.DuplicateDomainName, Enumerable.Empty<IDomain>());
}
// find the domains currently assigned to the content item
IDomain[] currentlyAssignedDomains = allDomains.Where(domain => domain.RootContentId == content.Id).ToArray();
// calculate the new domain assignments
IDomain[] newAssignedDomains = CalculateNewAssignedDomains(content.Id, updateModel, currentlyAssignedDomains, languageIdByIsoCode);
EventMessages eventMessages = EventMessagesFactory.Get();
scope.WriteLock(Constants.Locks.Domains);
// delete any obsolete domain assignments
if (DeleteAll(currentlyAssignedDomains.Except(newAssignedDomains).ToArray(), scope, eventMessages) == false)
{
scope.Complete();
// this is the only error scenario in DeleteAll
return Attempt.FailWithStatus(DomainOperationStatus.CancelledByNotification, newAssignedDomains.AsEnumerable());
}
// update all domain assignments (also current ones, in case sort order or ISO code has changed)
var result = SaveAll(newAssignedDomains, scope, eventMessages);
scope.Complete();
return result
? Attempt.SucceedWithStatus(DomainOperationStatus.Success, newAssignedDomains.AsEnumerable())
: Attempt.FailWithStatus(DomainOperationStatus.CancelledByNotification, newAssignedDomains.AsEnumerable());
}
/// <summary>
/// Tests if any of the ISO codes in the update model are invalid
/// </summary>
private bool HasInvalidIsoCode(DomainsUpdateModel updateModel, IEnumerable<string> allIsoCodes)
=> new[] { updateModel.DefaultIsoCode }
.Union(updateModel.Domains.Select(domainModel => domainModel.IsoCode))
.WhereNotNull()
.Except(allIsoCodes)
.Any();
/// <summary>
/// Tests if any of the domain names in the update model are assigned to other content items
/// </summary>
private bool HasDomainNameConflicts(int contentId, DomainsUpdateModel updateModel, IEnumerable<IDomain> allDomains)
{
var domainNamesAssignedToOtherContent = allDomains
.Where(domain => domain.IsWildcard == false && domain.RootContentId != contentId)
.Select(domain => domain.DomainName.ToLowerInvariant())
.ToArray();
return updateModel.Domains.Any(domainModel => domainNamesAssignedToOtherContent.InvariantContains(domainModel.DomainName));
}
/// <summary>
/// Calculates the new domain assignment incl. wildcard domains
/// </summary>
private IDomain[] CalculateNewAssignedDomains(int contentId, DomainsUpdateModel updateModel, IDomain[] currentlyAssignedDomains, IDictionary<string, int> languageIdByIsoCode)
{
// calculate the assigned domains as they should be after updating (including wildcard domains)
var newAssignedDomains = new List<IDomain>();
if (updateModel.DefaultIsoCode.IsNullOrWhiteSpace() == false)
{
IDomain defaultDomain = currentlyAssignedDomains.FirstOrDefault(domain => domain.IsWildcard && domain.LanguageIsoCode == updateModel.DefaultIsoCode)
?? new UmbracoDomain($"*{contentId}")
{
LanguageId = languageIdByIsoCode[updateModel.DefaultIsoCode],
RootContentId = contentId
};
// wildcard domains should have sort order -1 (lowest possible sort order)
defaultDomain.SortOrder = -1;
newAssignedDomains.Add(defaultDomain);
}
var sortOrder = 0;
foreach (DomainModel domainModel in updateModel.Domains)
{
IDomain assignedDomain = currentlyAssignedDomains.FirstOrDefault(domain => domainModel.DomainName.InvariantEquals(domain.DomainName))
?? new UmbracoDomain(domainModel.DomainName)
{
LanguageId = languageIdByIsoCode[domainModel.IsoCode],
RootContentId = contentId
};
assignedDomain.SortOrder = sortOrder++;
newAssignedDomains.Add(assignedDomain);
}
return newAssignedDomains.ToArray();
}
/// <summary>
/// Handles deletion of one or more domains incl. notifications
/// </summary>
private bool DeleteAll(IDomain[] domainsToDelete, ICoreScope scope, EventMessages eventMessages)
{
if (domainsToDelete.Any() == false)
{
return true;
}
var deletingNotification = new DomainDeletingNotification(domainsToDelete, eventMessages);
if (scope.Notifications.PublishCancelable(deletingNotification))
{
return false;
}
foreach (IDomain domainToDelete in domainsToDelete)
{
_domainRepository.Delete(domainToDelete);
}
scope.Notifications.Publish(new DomainDeletedNotification(domainsToDelete, eventMessages).WithStateFrom(deletingNotification));
return true;
}
/// <summary>
/// Handles saving of one or more domains incl. notifications
/// </summary>
private bool SaveAll(IDomain[] domainsToSave, ICoreScope scope, EventMessages eventMessages)
{
if (domainsToSave.Any() == false)
{
return true;
}
var savingNotification = new DomainSavingNotification(domainsToSave, eventMessages);
if (scope.Notifications.PublishCancelable(savingNotification))
{
return false;
}
foreach (IDomain assignedDomain in domainsToSave)
{
_domainRepository.Save(assignedDomain);
}
scope.Notifications.Publish(new DomainSavedNotification(domainsToSave, eventMessages).WithStateFrom(savingNotification));
return true;
}
}

View File

@@ -1,5 +1,7 @@
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Services.OperationStatus;
namespace Umbraco.Cms.Core.Services;
@@ -7,18 +9,43 @@ public interface IDomainService : IService
{
bool Exists(string domainName);
[Obsolete($"Please use {nameof(UpdateDomainsAsync)}. Will be removed in V15")]
Attempt<OperationResult?> Delete(IDomain domain);
IDomain? GetByName(string name);
IDomain? GetById(int id);
[Obsolete($"Please use {nameof(GetAllAsync)}. Will be removed in V15")]
IEnumerable<IDomain> GetAll(bool includeWildcards);
[Obsolete($"Please use {nameof(GetAssignedDomainsAsync)}. Will be removed in V15")]
IEnumerable<IDomain> GetAssignedDomains(int contentId, bool includeWildcards);
[Obsolete($"Please use {nameof(UpdateDomainsAsync)}. Will be removed in V15")]
Attempt<OperationResult?> Save(IDomain domainEntity);
[Obsolete($"Please use {nameof(UpdateDomainsAsync)}. Will be removed in V15")]
Attempt<OperationResult?> Sort(IEnumerable<IDomain> items)
=> Attempt.Fail(new OperationResult(OperationResultType.Failed, new EventMessages())); // TODO Remove default implmentation in a future version
/// <summary>
/// Gets all assigned domains for content item.
/// </summary>
/// <param name="contentKey">The key of the content item.</param>
/// <param name="includeWildcards">Whether or not to include wildcard domains.</param>
Task<IEnumerable<IDomain>> GetAssignedDomainsAsync(Guid contentKey, bool includeWildcards);
/// <summary>
/// Gets all assigned domains.
/// </summary>
/// <param name="includeWildcards">Whether or not to include wildcard domains.</param>
Task<IEnumerable<IDomain>> GetAllAsync(bool includeWildcards);
/// <summary>
/// Updates the domain assignments for a content item.
/// </summary>
/// <param name="contentKey">The key of the content item.</param>
/// <param name="updateModel">The domain assignments to apply.</param>
Task<Attempt<IEnumerable<IDomain>, DomainOperationStatus>> UpdateDomainsAsync(Guid contentKey, DomainsUpdateModel updateModel);
}

View File

@@ -0,0 +1,10 @@
namespace Umbraco.Cms.Core.Services.OperationStatus;
public enum DomainOperationStatus
{
Success,
CancelledByNotification,
ContentNotFound,
LanguageNotFound,
DuplicateDomainName
}

View File

@@ -11,6 +11,7 @@ using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Models.Membership;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Infrastructure.Persistence.Repositories.Implement;
@@ -82,18 +83,28 @@ public class TelemetryProviderTests : UmbracoIntegrationTest
}
[Test]
public void Domain_Telemetry_Provider_Can_Get_Domains()
public async Task Domain_Telemetry_Provider_Can_Get_Domains()
{
// Arrange
DomainService.Save(new UmbracoDomain("danish", "da-DK"));
var contentType = ContentTypeBuilder.CreateBasicContentType();
ContentTypeService.Save(contentType);
var content = ContentBuilder.CreateBasicContent(contentType);
ContentService.Save(content);
await DomainService.UpdateDomainsAsync(
content.Key,
new DomainsUpdateModel
{
Domains = new[] { new DomainModel { DomainName = "english", IsoCode = "en-US" } }
});
IEnumerable<UsageInformation> result = null;
// Act
result = DetailedTelemetryProviders.GetInformation();
IEnumerable<UsageInformation> result = DetailedTelemetryProviders.GetInformation();
// Assert
Assert.AreEqual(1, result.First().Data);
Assert.AreEqual("DomainCount", result.First().Name);
}
[Test]

View File

@@ -13,6 +13,7 @@ using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Persistence.Repositories;
using Umbraco.Cms.Core.PropertyEditors;
@@ -26,6 +27,7 @@ using Umbraco.Cms.Tests.Common.Extensions;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
using Umbraco.Extensions;
using Language = Umbraco.Cms.Core.Models.Language;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
@@ -1945,7 +1947,7 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent
}
[Test]
public void Can_Empty_RecycleBin_With_Content_That_Has_All_Related_Data()
public async Task Can_Empty_RecycleBin_With_Content_That_Has_All_Related_Data()
{
// Arrange
// need to:
@@ -1994,9 +1996,13 @@ public class ContentServiceTests : UmbracoIntegrationTestWithContent
Assert.IsNotNull(NotificationService.CreateNotification(user, content1, "X"));
ContentService.SetPermission(content1, 'A', new[] { userGroup.Id });
Assert.IsTrue(DomainService.Save(new UmbracoDomain("www.test.com", "en-AU") { RootContentId = content1.Id })
.Success);
var updateDomainResult = await DomainService.UpdateDomainsAsync(
content1.Key,
new DomainsUpdateModel
{
Domains = new[] { new DomainModel { DomainName = "www.test.com", IsoCode = "en-US" } }
});
Assert.IsTrue(updateDomainResult.Success);
// Act
ContentService.MoveToRecycleBin(content1);

View File

@@ -496,13 +496,23 @@ public class ContentControllerTests : UmbracoTestServerTestBase
.Build();
var enLanguage = await languageService.GetAsync(UsIso);
var domainService = GetRequiredService<IDomainService>();
var enDomain = new UmbracoDomain("/en") {RootContentId = content.Id, LanguageId = enLanguage.Id};
domainService.Save(enDomain);
var dkLanguage = await languageService.GetAsync(DkIso);
var dkDomain = new UmbracoDomain("/dk") {RootContentId = childContent.Id, LanguageId = dkLanguage.Id};
domainService.Save(dkDomain);
var domainService = GetRequiredService<IDomainService>();
await domainService.UpdateDomainsAsync(
content.Key,
new DomainsUpdateModel
{
Domains = new[] { new DomainModel { DomainName = "/en", IsoCode = enLanguage.IsoCode } }
});
await domainService.UpdateDomainsAsync(
childContent.Key,
new DomainsUpdateModel
{
Domains = new[] { new DomainModel { DomainName = "/dk", IsoCode = dkLanguage.IsoCode } }
});
var url = PrepareApiControllerUrl<ContentController>(x => x.PostSave(null));
@@ -559,8 +569,13 @@ public class ContentControllerTests : UmbracoTestServerTestBase
var dkLanguage = await languageService.GetAsync(DkIso);
var domainService = GetRequiredService<IDomainService>();
var dkDomain = new UmbracoDomain("/") {RootContentId = content.Id, LanguageId = dkLanguage.Id};
domainService.Save(dkDomain);
await domainService.UpdateDomainsAsync(
content.Key,
new DomainsUpdateModel
{
Domains = new[] { new DomainModel { DomainName = "/", IsoCode = dkLanguage.IsoCode } }
});
var url = PrepareApiControllerUrl<ContentController>(x => x.PostSave(null));
@@ -631,12 +646,20 @@ public class ContentControllerTests : UmbracoTestServerTestBase
var dkLanguage = await languageService.GetAsync(DkIso);
var usLanguage = await languageService.GetAsync(UsIso);
var domainService = GetRequiredService<IDomainService>();
var dkDomain = new UmbracoDomain("/") {RootContentId = rootNode.Id, LanguageId = dkLanguage.Id};
var usDomain = new UmbracoDomain("/en") {RootContentId = childNode.Id, LanguageId = usLanguage.Id};
await domainService.UpdateDomainsAsync(
rootNode.Key,
new DomainsUpdateModel
{
Domains = new[] { new DomainModel { DomainName = "/", IsoCode = dkLanguage.IsoCode } }
});
domainService.Save(dkDomain);
domainService.Save(usDomain);
await domainService.UpdateDomainsAsync(
childNode.Key,
new DomainsUpdateModel
{
Domains = new[] { new DomainModel { DomainName = "/en", IsoCode = usLanguage.IsoCode } }
});
var url = PrepareApiControllerUrl<ContentController>(x => x.PostSave(null));

View File

@@ -1,19 +1,17 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using NUnit.Framework;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.ContentEditing;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Packaging;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Tests.Common;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
using Umbraco.Extensions;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Web.BackOffice.UrlAndDomains;
@@ -74,17 +72,147 @@ public class DomainAndUrlsTests : UmbracoIntegrationTest
private readonly TestVariationContextAccessor _variationContextAccessor = new();
public IContent Root { get; set; }
public string[] Cultures { get; set; }
[Test]
public async Task Can_Update_Domains_For_All_Cultures()
{
var domainService = GetRequiredService<IDomainService>();
var updateModel = new DomainsUpdateModel
{
Domains = Cultures.Select(culture => new DomainModel
{
DomainName = GetDomainUrlFromCultureCode(culture), IsoCode = culture
})
};
var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel);
Assert.IsTrue(result.Success);
Assert.AreEqual(DomainOperationStatus.Success, result.Status);
void VerifyDomains(IDomain[] domains)
{
Assert.AreEqual(3, domains.Length);
for (var i = 0; i < domains.Length; i++)
{
Assert.AreEqual(Cultures[i], domains[i].LanguageIsoCode);
Assert.AreEqual(GetDomainUrlFromCultureCode(Cultures[i]), domains[i].DomainName);
}
}
VerifyDomains(result.Result.ToArray());
// re-get and verify again
var domains = await domainService.GetAssignedDomainsAsync(Root.Key, true);
VerifyDomains(domains.ToArray());
}
[Test]
public void Having_three_cultures_and_set_domain_on_all_of_them()
public async Task Can_Sort_Domains()
{
foreach (var culture in Cultures)
var domainService = GetRequiredService<IDomainService>();
var reversedCultures = Cultures.Reverse().ToArray();
var updateModel = new DomainsUpdateModel
{
SetDomainOnContent(Root, culture, GetDomainUrlFromCultureCode(culture));
Domains = reversedCultures.Select(culture => new DomainModel
{
DomainName = GetDomainUrlFromCultureCode(culture), IsoCode = culture
})
};
var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel);
Assert.IsTrue(result.Success);
Assert.AreEqual(DomainOperationStatus.Success, result.Status);
void VerifyDomains(IDomain[] domains)
{
Assert.AreEqual(3, domains.Length);
for (var i = 0; i < domains.Length; i++)
{
Assert.AreEqual(reversedCultures[i], domains[i].LanguageIsoCode);
Assert.AreEqual(GetDomainUrlFromCultureCode(reversedCultures[i]), domains[i].DomainName);
}
}
VerifyDomains(result.Result.ToArray());
// re-get and verify again
var domains = await domainService.GetAssignedDomainsAsync(Root.Key, true);
VerifyDomains(domains.ToArray());
}
[Test]
public async Task Can_Remove_All_Domains()
{
var domainService = GetRequiredService<IDomainService>();
var updateModel = new DomainsUpdateModel
{
Domains = Cultures.Select(culture => new DomainModel
{
DomainName = GetDomainUrlFromCultureCode(culture), IsoCode = culture
})
};
var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel);
Assert.IsTrue(result.Success);
Assert.AreEqual(DomainOperationStatus.Success, result.Status);
Assert.AreEqual(3, result.Result.Count());
updateModel.Domains = Enumerable.Empty<DomainModel>();
result = await domainService.UpdateDomainsAsync(Root.Key, updateModel);
Assert.IsTrue(result.Success);
Assert.AreEqual(DomainOperationStatus.Success, result.Status);
Assert.AreEqual(0, result.Result.Count());
// re-get and verify again
var domains = await domainService.GetAssignedDomainsAsync(Root.Key, true);
Assert.AreEqual(0, domains.Count());
}
[Test]
public async Task Can_Remove_Single_Domain()
{
var domainService = GetRequiredService<IDomainService>();
var updateModel = new DomainsUpdateModel
{
Domains = Cultures.Select(culture => new DomainModel
{
DomainName = GetDomainUrlFromCultureCode(culture), IsoCode = culture
})
};
var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel);
Assert.IsTrue(result.Success);
Assert.AreEqual(DomainOperationStatus.Success, result.Status);
Assert.AreEqual(3, result.Result.Count());
updateModel.Domains = new[] { updateModel.Domains.First(), updateModel.Domains.Last() };
result = await domainService.UpdateDomainsAsync(Root.Key, updateModel);
Assert.IsTrue(result.Success);
Assert.AreEqual(DomainOperationStatus.Success, result.Status);
Assert.AreEqual(2, result.Result.Count());
Assert.AreEqual(Cultures.First(), result.Result.First().LanguageIsoCode);
Assert.AreEqual(Cultures.Last(), result.Result.Last().LanguageIsoCode);
}
[Test]
public async Task Can_Resolve_Urls_With_Domains_For_All_Cultures()
{
var domainService = GetRequiredService<IDomainService>();
var updateModel = new DomainsUpdateModel
{
Domains = Cultures.Select(culture => new DomainModel
{
DomainName = GetDomainUrlFromCultureCode(culture), IsoCode = culture
})
};
var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel);
Assert.IsTrue(result.Success);
var rootUrls = GetContentUrlsAsync(Root).ToArray();
Assert.Multiple(() =>
@@ -100,11 +228,21 @@ public class DomainAndUrlsTests : UmbracoIntegrationTest
}
[Test]
public void Having_three_cultures_but_set_domain_on_a_non_default_language()
public async Task Can_Resolve_Urls_For_Non_Default_Domain_Culture_Only()
{
var culture = Cultures[1];
var domain = GetDomainUrlFromCultureCode(culture);
SetDomainOnContent(Root, culture, domain);
var domainService = GetRequiredService<IDomainService>();
var updateModel = new DomainsUpdateModel
{
Domains = new[]
{
new DomainModel { DomainName = domain, IsoCode = culture }
}
};
var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel);
Assert.IsTrue(result.Success);
var rootUrls = GetContentUrlsAsync(Root).ToArray();
@@ -124,6 +262,99 @@ public class DomainAndUrlsTests : UmbracoIntegrationTest
});
}
[Test]
public async Task Can_Set_Default_Culture()
{
var domainService = GetRequiredService<IDomainService>();
var culture = Cultures[1];
var updateModel = new DomainsUpdateModel
{
DefaultIsoCode = culture,
Domains = Enumerable.Empty<DomainModel>()
};
var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel);
Assert.IsTrue(result.Success);
Assert.AreEqual(1, result.Result.Count());
// default culture is represented as a wildcard domain
var domain = result.Result.First();
Assert.IsTrue(domain.IsWildcard);
Assert.AreEqual(culture, domain.LanguageIsoCode);
Assert.AreEqual("*" + Root.Id, domain.DomainName);
}
[Test]
public void Can_Use_Obsolete_Save()
{
foreach (var culture in Cultures)
{
SetDomainOnContent(Root, culture, GetDomainUrlFromCultureCode(culture));
}
var domains = GetRequiredService<IDomainService>().GetAssignedDomains(Root.Id, true);
Assert.AreEqual(3, domains.Count());
}
[Test]
public void Can_Use_Obsolete_Delete()
{
foreach (var culture in Cultures)
{
SetDomainOnContent(Root, culture, GetDomainUrlFromCultureCode(culture));
}
var domainService = GetRequiredService<IDomainService>();
var domains = domainService.GetAssignedDomains(Root.Id, true);
Assert.AreEqual(3, domains.Count());
var result = domainService.Delete(domains.First());
Assert.IsTrue(result.Success);
domains = domainService.GetAssignedDomains(Root.Id, true);
Assert.AreEqual(2, domains.Count());
}
[TestCase("/domain")]
[TestCase("/")]
[TestCase("some.domain.com")]
public async Task Cannot_Assign_Duplicate_Domains(string domainName)
{
var domainService = GetRequiredService<IDomainService>();
var updateModel = new DomainsUpdateModel
{
Domains = Cultures.Select(culture => new DomainModel { DomainName = domainName, IsoCode = culture }).ToArray()
};
var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel);
Assert.IsFalse(result.Success);
Assert.AreEqual(DomainOperationStatus.DuplicateDomainName, result.Status);
}
[Test]
public async Task Cannot_Assign_Already_Used_Domains()
{
var copy = ContentService.Copy(Root, Root.ParentId, false);
ContentService.SaveAndPublish(copy!);
var domainService = GetRequiredService<IDomainService>();
var updateModel = new DomainsUpdateModel
{
Domains = Cultures.Select(culture => new DomainModel
{
DomainName = GetDomainUrlFromCultureCode(culture), IsoCode = culture
})
};
var result = await domainService.UpdateDomainsAsync(Root.Key, updateModel);
Assert.IsTrue(result.Success);
result = await domainService.UpdateDomainsAsync(copy.Key, updateModel);
Assert.IsFalse(result.Success);
Assert.AreEqual(DomainOperationStatus.DuplicateDomainName, result.Status);
}
private static string GetDomainUrlFromCultureCode(string culture) =>
"/" + culture.Replace("-", string.Empty).ToLower() + "/";