Skip to content

Commit 1752be9

Browse files
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
1 parent ab98ea5 commit 1752be9

12 files changed

+310
-76
lines changed

src/Umbraco.Cms.Api.Management/Factories/DocumentUrlFactory.cs

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,38 @@
11
using Microsoft.Extensions.DependencyInjection;
2-
using Microsoft.Extensions.Logging;
32
using Umbraco.Cms.Api.Management.ViewModels.Document;
43
using Umbraco.Cms.Core.DependencyInjection;
54
using Umbraco.Cms.Core.Models;
6-
using Umbraco.Cms.Core.Models.PublishedContent;
7-
using Umbraco.Cms.Core.PublishedCache;
85
using Umbraco.Cms.Core.Routing;
96
using Umbraco.Cms.Core.Services;
10-
using Umbraco.Cms.Core.Services.Navigation;
11-
using Umbraco.Cms.Core.Web;
12-
using Umbraco.Extensions;
137

148
namespace Umbraco.Cms.Api.Management.Factories;
159

1610
public class DocumentUrlFactory : IDocumentUrlFactory
1711
{
18-
private readonly IDocumentUrlService _documentUrlService;
12+
private readonly IPublishedUrlInfoProvider _publishedUrlInfoProvider;
1913

2014

15+
[Obsolete("Use the constructor that takes all dependencies, scheduled for removal in v16")]
2116
public DocumentUrlFactory(IDocumentUrlService documentUrlService)
17+
: this(StaticServiceProvider.Instance.GetRequiredService<IPublishedUrlInfoProvider>())
2218
{
23-
_documentUrlService = documentUrlService;
19+
}
20+
21+
[Obsolete("Use the constructor that takes all dependencies, scheduled for removal in v16")]
22+
public DocumentUrlFactory(IDocumentUrlService documentUrlService, IPublishedUrlInfoProvider publishedUrlInfoProvider)
23+
: this(publishedUrlInfoProvider)
24+
{
25+
26+
}
27+
28+
public DocumentUrlFactory(IPublishedUrlInfoProvider publishedUrlInfoProvider)
29+
{
30+
_publishedUrlInfoProvider = publishedUrlInfoProvider;
2431
}
2532

2633
public async Task<IEnumerable<DocumentUrlInfo>> CreateUrlsAsync(IContent content)
2734
{
28-
IEnumerable<UrlInfo> urlInfos = await _documentUrlService.ListUrlsAsync(content.Key);
35+
ISet<UrlInfo> urlInfos = await _publishedUrlInfoProvider.GetAllAsync(content);
2936

3037
return urlInfos
3138
.Where(urlInfo => urlInfo.IsUrl)

src/Umbraco.Cms.Api.Management/Factories/IDocumentUrlFactory.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ namespace Umbraco.Cms.Api.Management.Factories;
66
public interface IDocumentUrlFactory
77
{
88
Task<IEnumerable<DocumentUrlInfo>> CreateUrlsAsync(IContent content);
9+
910
Task<IEnumerable<DocumentUrlInfoResponseModel>> CreateUrlSetsAsync(IEnumerable<IContent> contentItems);
1011
}

src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ private void AddCoreServices()
241241

242242
// register published router
243243
Services.AddUnique<IPublishedRouter, PublishedRouter>();
244+
Services.AddUnique<IPublishedUrlInfoProvider, PublishedUrlInfoProvider>();
244245

245246
Services.AddUnique<IEventMessagesFactory, DefaultEventMessagesFactory>();
246247
Services.AddUnique<IEventMessagesAccessor, HybridEventMessagesAccessor>();
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using Umbraco.Cms.Core.Models;
2+
3+
namespace Umbraco.Cms.Core.Routing;
4+
5+
public interface IPublishedUrlInfoProvider
6+
{
7+
/// <summary>
8+
/// Gets all published urls for a content item.
9+
/// </summary>
10+
/// <param name="content">The content to get urls for.</param>
11+
/// <returns>Set of all published url infos.</returns>
12+
Task<ISet<UrlInfo>> GetAllAsync(IContent content);
13+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
using Microsoft.Extensions.Logging;
2+
using Umbraco.Cms.Core.Models;
3+
using Umbraco.Cms.Core.Models.PublishedContent;
4+
using Umbraco.Cms.Core.Services;
5+
using Umbraco.Cms.Core.Web;
6+
using Umbraco.Extensions;
7+
8+
namespace Umbraco.Cms.Core.Routing;
9+
10+
public class PublishedUrlInfoProvider : IPublishedUrlInfoProvider
11+
{
12+
private readonly IPublishedUrlProvider _publishedUrlProvider;
13+
private readonly ILanguageService _languageService;
14+
private readonly IPublishedRouter _publishedRouter;
15+
private readonly IUmbracoContextAccessor _umbracoContextAccessor;
16+
private readonly ILocalizedTextService _localizedTextService;
17+
private readonly ILogger<PublishedUrlInfoProvider> _logger;
18+
private readonly UriUtility _uriUtility;
19+
private readonly IVariationContextAccessor _variationContextAccessor;
20+
21+
public PublishedUrlInfoProvider(
22+
IPublishedUrlProvider publishedUrlProvider,
23+
ILanguageService languageService,
24+
IPublishedRouter publishedRouter,
25+
IUmbracoContextAccessor umbracoContextAccessor,
26+
ILocalizedTextService localizedTextService,
27+
ILogger<PublishedUrlInfoProvider> logger,
28+
UriUtility uriUtility,
29+
IVariationContextAccessor variationContextAccessor)
30+
{
31+
_publishedUrlProvider = publishedUrlProvider;
32+
_languageService = languageService;
33+
_publishedRouter = publishedRouter;
34+
_umbracoContextAccessor = umbracoContextAccessor;
35+
_localizedTextService = localizedTextService;
36+
_logger = logger;
37+
_uriUtility = uriUtility;
38+
_variationContextAccessor = variationContextAccessor;
39+
}
40+
41+
/// <inheritdoc />
42+
public async Task<ISet<UrlInfo>> GetAllAsync(IContent content)
43+
{
44+
HashSet<UrlInfo> urlInfos = [];
45+
var cultures = (await _languageService.GetAllAsync()).Select(x => x.IsoCode).ToArray();
46+
47+
// First we get the urls of all cultures, using the published router, meaning we respect any extensions.
48+
foreach (var culture in cultures)
49+
{
50+
var url = _publishedUrlProvider.GetUrl(content.Key, culture: culture);
51+
52+
// Handle "could not get URL"
53+
if (url is "#" or "#ex")
54+
{
55+
urlInfos.Add(UrlInfo.Message(_localizedTextService.Localize("content", "getUrlException"), culture));
56+
continue;
57+
}
58+
59+
// Check for collision
60+
Attempt<UrlInfo?> hasCollision = await VerifyCollisionAsync(content, url, culture);
61+
62+
if (hasCollision is { Success: true, Result: not null })
63+
{
64+
urlInfos.Add(hasCollision.Result);
65+
continue;
66+
}
67+
68+
urlInfos.Add(UrlInfo.Url(url, culture));
69+
}
70+
71+
// Then get "other" urls - I.E. Not what you'd get with GetUrl(), this includes all the urls registered using domains.
72+
// for these 'other' URLs, we don't check whether they are routable, collide, anything - we just report them.
73+
foreach (UrlInfo otherUrl in _publishedUrlProvider.GetOtherUrls(content.Id).OrderBy(x => x.Text).ThenBy(x => x.Culture))
74+
{
75+
urlInfos.Add(otherUrl);
76+
}
77+
78+
return urlInfos;
79+
}
80+
81+
private async Task<Attempt<UrlInfo?>> VerifyCollisionAsync(IContent content, string url, string culture)
82+
{
83+
var uri = new Uri(url.TrimEnd(Constants.CharArrays.ForwardSlash), UriKind.RelativeOrAbsolute);
84+
if (uri.IsAbsoluteUri is false)
85+
{
86+
uri = uri.MakeAbsolute(_umbracoContextAccessor.GetRequiredUmbracoContext().CleanedUmbracoUrl);
87+
}
88+
89+
uri = _uriUtility.UriToUmbraco(uri);
90+
IPublishedRequestBuilder builder = await _publishedRouter.CreateRequestAsync(uri);
91+
IPublishedRequest publishedRequest = await _publishedRouter.RouteRequestAsync(builder, new RouteRequestOptions(RouteDirection.Outbound));
92+
93+
if (publishedRequest.HasPublishedContent() is false)
94+
{
95+
if (_logger.IsEnabled(LogLevel.Debug))
96+
{
97+
const string logMsg = nameof(VerifyCollisionAsync) +
98+
" did not resolve a content item for original url: {Url}, translated to {TranslatedUrl} and culture: {Culture}";
99+
_logger.LogDebug(logMsg, url, uri, culture);
100+
}
101+
102+
var urlInfo = UrlInfo.Message(_localizedTextService.Localize("content", "routeErrorCannotRoute"), culture);
103+
return Attempt.Succeed(urlInfo);
104+
}
105+
106+
if (publishedRequest.IgnorePublishedContentCollisions)
107+
{
108+
return Attempt<UrlInfo?>.Fail();
109+
}
110+
111+
if (publishedRequest.PublishedContent?.Id != content.Id)
112+
{
113+
var collidingContent = publishedRequest.PublishedContent?.Key.ToString();
114+
115+
var urlInfo = UrlInfo.Message(_localizedTextService.Localize("content", "routeError", [collidingContent]), culture);
116+
return Attempt.Succeed(urlInfo);
117+
}
118+
119+
// No collision
120+
return Attempt<UrlInfo?>.Fail();
121+
}
122+
}

src/Umbraco.Core/Services/DocumentUrlService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,7 @@ public bool HasAny()
525525
}
526526

527527

528+
[Obsolete("This method is obsolete and will be removed in future versions. Use IPublishedUrlInfoProvider.GetAllAsync instead.")]
528529
public async Task<IEnumerable<UrlInfo>> ListUrlsAsync(Guid contentKey)
529530
{
530531
var result = new List<UrlInfo>();

tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest.cs

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -213,44 +213,6 @@ public void Unpublished_Pages_Are_not_available()
213213
return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper();
214214
}
215215

216-
[Test]
217-
public async Task Two_items_in_level_1_with_same_name_will_have_conflicting_routes()
218-
{
219-
// Create a second root
220-
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);
221-
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
222-
ContentService.Save(secondRoot, -1, contentSchedule);
223-
224-
// Create a child of second root
225-
var childOfSecondRoot = ContentBuilder.CreateSimpleContent(ContentType, Subpage.Name, secondRoot);
226-
childOfSecondRoot.Key = new Guid("FF6654FB-BC68-4A65-8C6C-135567F50BD6");
227-
ContentService.Save(childOfSecondRoot, -1, contentSchedule);
228-
229-
// Publish both the main root and the second root with descendants
230-
ContentService.PublishBranch(Textpage, true, new[] { "*" });
231-
ContentService.PublishBranch(secondRoot, true, new[] { "*" });
232-
233-
var subPageUrls = await DocumentUrlService.ListUrlsAsync(Subpage.Key);
234-
var childOfSecondRootUrls = await DocumentUrlService.ListUrlsAsync(childOfSecondRoot.Key);
235-
236-
//Assert the url of subpage is correct
237-
Assert.AreEqual(1, subPageUrls.Count());
238-
Assert.IsTrue(subPageUrls.First().IsUrl);
239-
Assert.AreEqual("/text-page-1", subPageUrls.First().Text);
240-
Assert.AreEqual(Subpage.Key, DocumentUrlService.GetDocumentKeyByRoute("/text-page-1", "en-US", null, false));
241-
242-
//Assert the url of child of second root is not exposed
243-
Assert.AreEqual(1, childOfSecondRootUrls.Count());
244-
Assert.IsFalse(childOfSecondRootUrls.First().IsUrl);
245-
246-
//Ensure the url without hide top level is not finding the child of second root
247-
Assert.AreNotEqual(childOfSecondRoot.Key, DocumentUrlService.GetDocumentKeyByRoute("/second-root/text-page-1", "en-US", null, false));
248-
249-
250-
251-
}
252-
253-
254216

255217
//TODO test cases:
256218
// - Find the root, when a domain is set

tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTest_hidetoplevel_false.cs

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -120,33 +120,4 @@ public override void Setup()
120120

121121
return DocumentUrlService.GetDocumentKeyByRoute(route, isoCode, null, loadDraft)?.ToString()?.ToUpper();
122122
}
123-
124-
[Test]
125-
public async Task Two_items_in_level_1_with_same_name_will_not_have_conflicting_routes()
126-
{
127-
// Create a second root
128-
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);
129-
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
130-
ContentService.Save(secondRoot, -1, contentSchedule);
131-
132-
// Create a child of second root
133-
var childOfSecondRoot = ContentBuilder.CreateSimpleContent(ContentType, Subpage.Name, secondRoot);
134-
childOfSecondRoot.Key = new Guid("FF6654FB-BC68-4A65-8C6C-135567F50BD6");
135-
ContentService.Save(childOfSecondRoot, -1, contentSchedule);
136-
137-
// Publish both the main root and the second root with descendants
138-
ContentService.PublishBranch(Textpage, true, new[] { "*" });
139-
ContentService.PublishBranch(secondRoot, true, new[] { "*" });
140-
141-
var subPageUrls = await DocumentUrlService.ListUrlsAsync(Subpage.Key);
142-
var childOfSecondRootUrls = await DocumentUrlService.ListUrlsAsync(childOfSecondRoot.Key);
143-
144-
Assert.AreEqual(1, subPageUrls.Count());
145-
Assert.IsTrue(subPageUrls.First().IsUrl);
146-
Assert.AreEqual("/textpage/text-page-1", subPageUrls.First().Text);
147-
148-
Assert.AreEqual(1, childOfSecondRootUrls.Count());
149-
Assert.IsTrue(childOfSecondRootUrls.First().IsUrl);
150-
Assert.AreEqual("/second-root/text-page-1", childOfSecondRootUrls.First().Text);
151-
}
152123
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using NUnit.Framework;
2+
using Umbraco.Cms.Core.Models;
3+
using Umbraco.Cms.Tests.Common.Builders;
4+
5+
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services;
6+
7+
public class PublishedUrlInfoProviderTests : PublishedUrlInfoProviderTestsBase
8+
{
9+
10+
[Test]
11+
public async Task Two_items_in_level_1_with_same_name_will_have_conflicting_routes()
12+
{
13+
// Create a second root
14+
var secondRoot = ContentBuilder.CreateSimpleContent(ContentType, "Second Root", null);
15+
var contentSchedule = ContentScheduleCollection.CreateWithEntry(DateTime.Now.AddMinutes(-5), null);
16+
ContentService.Save(secondRoot, -1, contentSchedule);
17+
18+
// Create a child of second root
19+
var childOfSecondRoot = ContentBuilder.CreateSimpleContent(ContentType, Subpage.Name, secondRoot);
20+
childOfSecondRoot.Key = new Guid("FF6654FB-BC68-4A65-8C6C-135567F50BD6");
21+
ContentService.Save(childOfSecondRoot, -1, contentSchedule);
22+
23+
// Publish both the main root and the second root with descendants
24+
ContentService.PublishBranch(Textpage, true, new[] { "*" });
25+
ContentService.PublishBranch(secondRoot, true, new[] { "*" });
26+
27+
var subPageUrls = await PublishedUrlInfoProvider.GetAllAsync(Subpage);
28+
var childOfSecondRootUrls = await PublishedUrlInfoProvider.GetAllAsync(childOfSecondRoot);
29+
30+
// Assert the url of subpage is correct
31+
Assert.AreEqual(1, subPageUrls.Count);
32+
Assert.IsTrue(subPageUrls.First().IsUrl);
33+
Assert.AreEqual("/text-page-1/", subPageUrls.First().Text);
34+
Assert.AreEqual(Subpage.Key, DocumentUrlService.GetDocumentKeyByRoute("/text-page-1/", "en-US", null, false));
35+
36+
// Assert the url of child of second root is not exposed
37+
Assert.AreEqual(1, childOfSecondRootUrls.Count);
38+
Assert.IsFalse(childOfSecondRootUrls.First().IsUrl);
39+
40+
// Ensure the url without hide top level is not finding the child of second root
41+
Assert.AreNotEqual(childOfSecondRoot.Key, DocumentUrlService.GetDocumentKeyByRoute("/second-root/text-page-1/", "en-US", null, false));
42+
}
43+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using Microsoft.AspNetCore.Http;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using Moq;
4+
using NUnit.Framework;
5+
using Umbraco.Cms.Core.Cache;
6+
using Umbraco.Cms.Core.Notifications;
7+
using Umbraco.Cms.Core.PublishedCache;
8+
using Umbraco.Cms.Core.Routing;
9+
using Umbraco.Cms.Core.Services;
10+
using Umbraco.Cms.Core.Sync;
11+
using Umbraco.Cms.Core.Web;
12+
using Umbraco.Cms.Tests.Common;
13+
using Umbraco.Cms.Tests.Common.Testing;
14+
using Umbraco.Cms.Tests.Integration.Testing;
15+
using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Scoping;
16+
17+
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.Services;
18+
19+
[TestFixture]
20+
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, Logger = UmbracoTestOptions.Logger.Mock)]
21+
public abstract class PublishedUrlInfoProviderTestsBase : UmbracoIntegrationTestWithContent
22+
{
23+
protected IDocumentUrlService DocumentUrlService => GetRequiredService<IDocumentUrlService>();
24+
25+
protected IPublishedUrlInfoProvider PublishedUrlInfoProvider => GetRequiredService<IPublishedUrlInfoProvider>();
26+
27+
protected override void CustomTestSetup(IUmbracoBuilder builder)
28+
{
29+
builder.Services.AddUnique<IServerMessenger, ScopedRepositoryTests.LocalServerMessenger>();
30+
builder.AddNotificationHandler<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>();
31+
builder.Services.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, DocumentUrlServiceInitializerNotificationHandler>();
32+
builder.Services.AddUnique<IUmbracoContextAccessor>(serviceProvider => new TestUmbracoContextAccessor(GetUmbracoContext(serviceProvider)));
33+
builder.Services.AddUnique(CreateHttpContextAccessor());
34+
}
35+
36+
public override void Setup()
37+
{
38+
DocumentUrlService.InitAsync(false, CancellationToken.None).GetAwaiter().GetResult();
39+
base.Setup();
40+
}
41+
42+
private IUmbracoContext GetUmbracoContext(IServiceProvider serviceProvider)
43+
{
44+
var mock = new Mock<IUmbracoContext>();
45+
46+
mock.Setup(x => x.Content).Returns(serviceProvider.GetRequiredService<IPublishedContentCache>());
47+
mock.Setup(x => x.CleanedUmbracoUrl).Returns(new Uri("https://localhost:44339"));
48+
49+
return mock.Object;
50+
}
51+
52+
private IHttpContextAccessor CreateHttpContextAccessor()
53+
{
54+
var mock = new Mock<IHttpContextAccessor>();
55+
var httpContext = new DefaultHttpContext();
56+
httpContext.Request.Scheme = "https";
57+
httpContext.Request.Host = new HostString("localhost");
58+
59+
mock.Setup(x => x.HttpContext).Returns(httpContext);
60+
return mock.Object;
61+
}
62+
}

0 commit comments

Comments
 (0)