V15: Fix Url Preview (#18072)

* Make URL overview align with the old routing

This means including custom url providers, other URLS, etc.

* Move implementation to its own provider

* Handle could not get url

* Migrate intergration tests to new implementation
This commit is contained in:
Mole
2025-01-29 13:59:58 +01:00
committed by GitHub
parent ab98ea58b5
commit 1752be989d
12 changed files with 310 additions and 76 deletions

View File

@@ -1,31 +1,38 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Api.Management.ViewModels.Document;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.Navigation;
using Umbraco.Cms.Core.Web;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Management.Factories;
public class DocumentUrlFactory : IDocumentUrlFactory
{
private readonly IDocumentUrlService _documentUrlService;
private readonly IPublishedUrlInfoProvider _publishedUrlInfoProvider;
[Obsolete("Use the constructor that takes all dependencies, scheduled for removal in v16")]
public DocumentUrlFactory(IDocumentUrlService documentUrlService)
: this(StaticServiceProvider.Instance.GetRequiredService<IPublishedUrlInfoProvider>())
{
_documentUrlService = documentUrlService;
}
[Obsolete("Use the constructor that takes all dependencies, scheduled for removal in v16")]
public DocumentUrlFactory(IDocumentUrlService documentUrlService, IPublishedUrlInfoProvider publishedUrlInfoProvider)
: this(publishedUrlInfoProvider)
{
}
public DocumentUrlFactory(IPublishedUrlInfoProvider publishedUrlInfoProvider)
{
_publishedUrlInfoProvider = publishedUrlInfoProvider;
}
public async Task<IEnumerable<DocumentUrlInfo>> CreateUrlsAsync(IContent content)
{
IEnumerable<UrlInfo> urlInfos = await _documentUrlService.ListUrlsAsync(content.Key);
ISet<UrlInfo> urlInfos = await _publishedUrlInfoProvider.GetAllAsync(content);
return urlInfos
.Where(urlInfo => urlInfo.IsUrl)

View File

@@ -6,5 +6,6 @@ namespace Umbraco.Cms.Api.Management.Factories;
public interface IDocumentUrlFactory
{
Task<IEnumerable<DocumentUrlInfo>> CreateUrlsAsync(IContent content);
Task<IEnumerable<DocumentUrlInfoResponseModel>> CreateUrlSetsAsync(IEnumerable<IContent> contentItems);
}

View File

@@ -241,6 +241,7 @@ namespace Umbraco.Cms.Core.DependencyInjection
// register published router
Services.AddUnique<IPublishedRouter, PublishedRouter>();
Services.AddUnique<IPublishedUrlInfoProvider, PublishedUrlInfoProvider>();
Services.AddUnique<IEventMessagesFactory, DefaultEventMessagesFactory>();
Services.AddUnique<IEventMessagesAccessor, HybridEventMessagesAccessor>();

View File

@@ -0,0 +1,13 @@
using Umbraco.Cms.Core.Models;
namespace Umbraco.Cms.Core.Routing;
public interface IPublishedUrlInfoProvider
{
/// <summary>
/// Gets all published urls for a content item.
/// </summary>
/// <param name="content">The content to get urls for.</param>
/// <returns>Set of all published url infos.</returns>
Task<ISet<UrlInfo>> GetAllAsync(IContent content);
}

View File

@@ -0,0 +1,122 @@
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Web;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.Routing;
public class PublishedUrlInfoProvider : IPublishedUrlInfoProvider
{
private readonly IPublishedUrlProvider _publishedUrlProvider;
private readonly ILanguageService _languageService;
private readonly IPublishedRouter _publishedRouter;
private readonly IUmbracoContextAccessor _umbracoContextAccessor;
private readonly ILocalizedTextService _localizedTextService;
private readonly ILogger<PublishedUrlInfoProvider> _logger;
private readonly UriUtility _uriUtility;
private readonly IVariationContextAccessor _variationContextAccessor;
public PublishedUrlInfoProvider(
IPublishedUrlProvider publishedUrlProvider,
ILanguageService languageService,
IPublishedRouter publishedRouter,
IUmbracoContextAccessor umbracoContextAccessor,
ILocalizedTextService localizedTextService,
ILogger<PublishedUrlInfoProvider> logger,
UriUtility uriUtility,
IVariationContextAccessor variationContextAccessor)
{
_publishedUrlProvider = publishedUrlProvider;
_languageService = languageService;
_publishedRouter = publishedRouter;
_umbracoContextAccessor = umbracoContextAccessor;
_localizedTextService = localizedTextService;
_logger = logger;
_uriUtility = uriUtility;
_variationContextAccessor = variationContextAccessor;
}
/// <inheritdoc />
public async Task<ISet<UrlInfo>> GetAllAsync(IContent content)
{
HashSet<UrlInfo> urlInfos = [];
var cultures = (await _languageService.GetAllAsync()).Select(x => x.IsoCode).ToArray();
// First we get the urls of all cultures, using the published router, meaning we respect any extensions.
foreach (var culture in cultures)
{
var url = _publishedUrlProvider.GetUrl(content.Key, culture: culture);
// Handle "could not get URL"
if (url is "#" or "#ex")
{
urlInfos.Add(UrlInfo.Message(_localizedTextService.Localize("content", "getUrlException"), culture));
continue;
}
// Check for collision
Attempt<UrlInfo?> hasCollision = await VerifyCollisionAsync(content, url, culture);
if (hasCollision is { Success: true, Result: not null })
{
urlInfos.Add(hasCollision.Result);
continue;
}
urlInfos.Add(UrlInfo.Url(url, culture));
}
// Then get "other" urls - I.E. Not what you'd get with GetUrl(), this includes all the urls registered using domains.
// for these 'other' URLs, we don't check whether they are routable, collide, anything - we just report them.
foreach (UrlInfo otherUrl in _publishedUrlProvider.GetOtherUrls(content.Id).OrderBy(x => x.Text).ThenBy(x => x.Culture))
{
urlInfos.Add(otherUrl);
}
return urlInfos;
}
private async Task<Attempt<UrlInfo?>> VerifyCollisionAsync(IContent content, string url, string culture)
{
var uri = new Uri(url.TrimEnd(Constants.CharArrays.ForwardSlash), UriKind.RelativeOrAbsolute);
if (uri.IsAbsoluteUri is false)
{
uri = uri.MakeAbsolute(_umbracoContextAccessor.GetRequiredUmbracoContext().CleanedUmbracoUrl);
}
uri = _uriUtility.UriToUmbraco(uri);
IPublishedRequestBuilder builder = await _publishedRouter.CreateRequestAsync(uri);
IPublishedRequest publishedRequest = await _publishedRouter.RouteRequestAsync(builder, new RouteRequestOptions(RouteDirection.Outbound));
if (publishedRequest.HasPublishedContent() is false)
{
if (_logger.IsEnabled(LogLevel.Debug))
{
const string logMsg = nameof(VerifyCollisionAsync) +
" did not resolve a content item for original url: {Url}, translated to {TranslatedUrl} and culture: {Culture}";
_logger.LogDebug(logMsg, url, uri, culture);
}
var urlInfo = UrlInfo.Message(_localizedTextService.Localize("content", "routeErrorCannotRoute"), culture);
return Attempt.Succeed(urlInfo);
}
if (publishedRequest.IgnorePublishedContentCollisions)
{
return Attempt<UrlInfo?>.Fail();
}
if (publishedRequest.PublishedContent?.Id != content.Id)
{
var collidingContent = publishedRequest.PublishedContent?.Key.ToString();
var urlInfo = UrlInfo.Message(_localizedTextService.Localize("content", "routeError", [collidingContent]), culture);
return Attempt.Succeed(urlInfo);
}
// No collision
return Attempt<UrlInfo?>.Fail();
}
}

View File

@@ -525,6 +525,7 @@ public class DocumentUrlService : IDocumentUrlService
}
[Obsolete("This method is obsolete and will be removed in future versions. Use IPublishedUrlInfoProvider.GetAllAsync instead.")]
public async Task<IEnumerable<UrlInfo>> ListUrlsAsync(Guid contentKey)
{
var result = new List<UrlInfo>();

View File

@@ -213,44 +213,6 @@ public class DocumentUrlServiceTest : UmbracoIntegrationTestWithContent
return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper();
}
[Test]
public async Task Two_items_in_level_1_with_same_name_will_have_conflicting_routes()
{
// Create a second root
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
ContentService.Save(secondRoot, -1, contentSchedule);
// Create a child of second root
var childOfSecondRoot = ContentBuilder.CreateSimpleContent(ContentType, Subpage.Name, secondRoot);
childOfSecondRoot.Key = new Guid("FF6654FB-BC68-4A65-8C6C-135567F50BD6");
ContentService.Save(childOfSecondRoot, -1, contentSchedule);
// Publish both the main root and the second root with descendants
ContentService.PublishBranch(Textpage, true, new[] { "*" });
ContentService.PublishBranch(secondRoot, true, new[] { "*" });
var subPageUrls = await DocumentUrlService.ListUrlsAsync(Subpage.Key);
var childOfSecondRootUrls = await DocumentUrlService.ListUrlsAsync(childOfSecondRoot.Key);
//Assert the url of subpage is correct
Assert.AreEqual(1, subPageUrls.Count());
Assert.IsTrue(subPageUrls.First().IsUrl);
Assert.AreEqual("/text-page-1", subPageUrls.First().Text);
Assert.AreEqual(Subpage.Key, DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, false));
//Assert the url of child of second root is not exposed
Assert.AreEqual(1, childOfSecondRootUrls.Count());
Assert.IsFalse(childOfSecondRootUrls.First().IsUrl);
//Ensure the url without hide top level is not finding the child of second root
Assert.AreNotEqual(childOfSecondRoot.Key, DocumentUrlService.GetDocumentKeyByRoute("/second-root/text-page-1", "en-US", null, false));
}
//TODO test cases:
// - Find the root, when a domain is set

View File

@@ -120,33 +120,4 @@ public class DocumentUrlServiceTest_HideTopLevel_False : UmbracoIntegrationTestW
return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper();
}
[Test]
public async Task Two_items_in_level_1_with_same_name_will_not_have_conflicting_routes()
{
// Create a second root
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
ContentService.Save(secondRoot, -1, contentSchedule);
// Create a child of second root
var childOfSecondRoot = ContentBuilder.CreateSimpleContent(ContentType, Subpage.Name, secondRoot);
childOfSecondRoot.Key = new Guid("FF6654FB-BC68-4A65-8C6C-135567F50BD6");
ContentService.Save(childOfSecondRoot, -1, contentSchedule);
// Publish both the main root and the second root with descendants
ContentService.PublishBranch(Textpage, true, new[] { "*" });
ContentService.PublishBranch(secondRoot, true, new[] { "*" });
var subPageUrls = await DocumentUrlService.ListUrlsAsync(Subpage.Key);
var childOfSecondRootUrls = await DocumentUrlService.ListUrlsAsync(childOfSecondRoot.Key);
Assert.AreEqual(1, subPageUrls.Count());
Assert.IsTrue(subPageUrls.First().IsUrl);
Assert.AreEqual("/textpage/text-page-1", subPageUrls.First().Text);
Assert.AreEqual(1, childOfSecondRootUrls.Count());
Assert.IsTrue(childOfSecondRootUrls.First().IsUrl);
Assert.AreEqual("/second-root/text-page-1", childOfSecondRootUrls.First().Text);
}
}

View File

@@ -0,0 +1,43 @@
using NUnit.Framework;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Tests.Common.Builders;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services;
public class PublishedUrlInfoProviderTests : PublishedUrlInfoProviderTestsBase
{
[Test]
public async Task Two_items_in_level_1_with_same_name_will_have_conflicting_routes()
{
// Create a second root
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
ContentService.Save(secondRoot, -1, contentSchedule);
// Create a child of second root
var childOfSecondRoot = ContentBuilder.CreateSimpleContent(ContentType, Subpage.Name, secondRoot);
childOfSecondRoot.Key = new Guid("FF6654FB-BC68-4A65-8C6C-135567F50BD6");
ContentService.Save(childOfSecondRoot, -1, contentSchedule);
// Publish both the main root and the second root with descendants
ContentService.PublishBranch(Textpage, true, new[] { "*" });
ContentService.PublishBranch(secondRoot, true, new[] { "*" });
var subPageUrls = await PublishedUrlInfoProvider.GetAllAsync(Subpage);
var childOfSecondRootUrls = await PublishedUrlInfoProvider.GetAllAsync(childOfSecondRoot);
// Assert the url of subpage is correct
Assert.AreEqual(1, subPageUrls.Count);
Assert.IsTrue(subPageUrls.First().IsUrl);
Assert.AreEqual("/text-page-1/", subPageUrls.First().Text);
Assert.AreEqual(Subpage.Key, DocumentUrlService.GetDocumentKeyByRoute("/text-page-1/", "en-US", null, false));
// Assert the url of child of second root is not exposed
Assert.AreEqual(1, childOfSecondRootUrls.Count);
Assert.IsFalse(childOfSecondRootUrls.First().IsUrl);
// Ensure the url without hide top level is not finding the child of second root
Assert.AreNotEqual(childOfSecondRoot.Key, DocumentUrlService.GetDocumentKeyByRoute("/second-root/text-page-1/", "en-US", null, false));
}
}

View File

@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Sync;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Tests.Common;
using Umbraco.Cms.Tests.Common.Testing;
using Umbraco.Cms.Tests.Integration.Testing;
using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Scoping;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services;
[TestFixture]
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Logger = UmbracoTestOptions.Logger.Mock)]
public abstract class PublishedUrlInfoProviderTestsBase : UmbracoIntegrationTestWithContent
{
protected IDocumentUrlService DocumentUrlService => GetRequiredService<IDocumentUrlService>();
protected IPublishedUrlInfoProvider PublishedUrlInfoProvider => GetRequiredService<IPublishedUrlInfoProvider>();
protected override void CustomTestSetup(IUmbracoBuilder builder)
{
builder.Services.AddUnique<IServerMessenger, ScopedRepositoryTests.LocalServerMessenger>();
builder.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>();
builder.Services.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, DocumentUrlServiceInitializerNotificationHandler>();
builder.Services.AddUnique<IUmbracoContextAccessor>(serviceProvider => new TestUmbracoContextAccessor(GetUmbracoContext(serviceProvider)));
builder.Services.AddUnique(CreateHttpContextAccessor());
}
public override void Setup()
{
DocumentUrlService.InitAsync(false, CancellationToken.None).GetAwaiter().GetResult();
base.Setup();
}
private IUmbracoContext GetUmbracoContext(IServiceProvider serviceProvider)
{
var mock = new Mock<IUmbracoContext>();
mock.Setup(x => x.Content).Returns(serviceProvider.GetRequiredService<IPublishedContentCache>());
mock.Setup(x => x.CleanedUmbracoUrl).Returns(new Uri("https://localhost:44339"));
return mock.Object;
}
private IHttpContextAccessor CreateHttpContextAccessor()
{
var mock = new Mock<IHttpContextAccessor>();
var httpContext = new DefaultHttpContext();
httpContext.Request.Scheme = "https";
httpContext.Request.Host = new HostString("localhost");
mock.Setup(x => x.HttpContext).Returns(httpContext);
return mock.Object;
}
}

View File

@@ -0,0 +1,45 @@
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Tests.Common.Builders;
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services;
public class PublishedUrlInfoProvider_hidetoplevel_false : PublishedUrlInfoProviderTestsBase
{
protected override void CustomTestSetup(IUmbracoBuilder builder)
{
builder.Services.Configure<GlobalSettings>(x => x.HideTopLevelNodeFromPath = false);
base.CustomTestSetup(builder);
}
[Test]
public async Task Two_items_in_level_1_with_same_name_will_not_have_conflicting_routes()
{
// Create a second root
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
ContentService.Save(secondRoot, -1, contentSchedule);
// Create a child of second root
var childOfSecondRoot = ContentBuilder.CreateSimpleContent(ContentType, Subpage.Name, secondRoot);
childOfSecondRoot.Key = new Guid("FF6654FB-BC68-4A65-8C6C-135567F50BD6");
ContentService.Save(childOfSecondRoot, -1, contentSchedule);
// Publish both the main root and the second root with descendants
ContentService.PublishBranch(Textpage, true, new[] { "*" });
ContentService.PublishBranch(secondRoot, true, new[] { "*" });
var subPageUrls = await PublishedUrlInfoProvider.GetAllAsync(Subpage);
var childOfSecondRootUrls = await PublishedUrlInfoProvider.GetAllAsync(childOfSecondRoot);
Assert.AreEqual(1, subPageUrls.Count);
Assert.IsTrue(subPageUrls.First().IsUrl);
Assert.AreEqual("/textpage/text-page-1/", subPageUrls.First().Text);
Assert.AreEqual(1, childOfSecondRootUrls.Count);
Assert.IsTrue(childOfSecondRootUrls.First().IsUrl);
Assert.AreEqual("/second-root/text-page-1/", childOfSecondRootUrls.First().Text);
}
}

View File

@@ -247,5 +247,11 @@
<Compile Update="Umbraco.Core\Services\MediaNavigationServiceTests.Update.cs">
<DependentUpon>MediaNavigationServiceTests.cs</DependentUpon>
</Compile>
<Compile Update="Umbraco.Core\Services\PublishedUrlInfoProvider_hidetoplevel_false.cs">
<DependentUpon>PublishedUrlInfoProviderTestsBase.cs</DependentUpon>
</Compile>
<Compile Update="Umbraco.Core\Services\PublishedUrlInfoProviderTests.cs">
<DependentUpon>PublishedUrlInfoProviderTestsBase.cs</DependentUpon>
</Compile>
</ItemGroup>
</Project>