From e7ccfaaaacd5df164d63472a034e72af076d8093 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 29 Oct 2025 09:47:17 +0100 Subject: [PATCH] Routing: Added method to `IDocumentUrlService` for retrieving document key from URI (closes #20666) (#20673) Added method to IDocumentUrlService for retrieving document key from URI. --- src/Umbraco.Core/Routing/DomainUtilities.cs | 2 - .../Services/DocumentUrlService.cs | 78 +++++++++++++++++++ .../Services/IDocumentUrlService.cs | 9 ++- .../Builders/TemplateBuilder.cs | 4 +- .../Services/DocumentUrlServiceTests.cs | 64 ++++++++++++++- 5 files changed, 151 insertions(+), 6 deletions(-) diff --git a/src/Umbraco.Core/Routing/DomainUtilities.cs b/src/Umbraco.Core/Routing/DomainUtilities.cs index 4127036721..4b18153898 100644 --- a/src/Umbraco.Core/Routing/DomainUtilities.cs +++ b/src/Umbraco.Core/Routing/DomainUtilities.cs @@ -1,7 +1,5 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; -using Microsoft.Extensions.DependencyInjection; -using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Services.Navigation; diff --git a/src/Umbraco.Core/Services/DocumentUrlService.cs b/src/Umbraco.Core/Services/DocumentUrlService.cs index 6cee0e0f5c..c2b65a097d 100644 --- a/src/Umbraco.Core/Services/DocumentUrlService.cs +++ b/src/Umbraco.Core/Services/DocumentUrlService.cs @@ -2,9 +2,11 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Runtime.CompilerServices; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core.Configuration.Models; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.PublishedCache; @@ -28,6 +30,7 @@ public class DocumentUrlService : IDocumentUrlService private readonly IDocumentRepository _documentRepository; private readonly ICoreScopeProvider _coreScopeProvider; private readonly GlobalSettings _globalSettings; + private readonly WebRoutingSettings _webRoutingSettings; private readonly UrlSegmentProviderCollection _urlSegmentProviderCollection; private readonly IContentService _contentService; private readonly IShortStringHelper _shortStringHelper; @@ -37,6 +40,7 @@ public class DocumentUrlService : IDocumentUrlService private readonly IDocumentNavigationQueryService _documentNavigationQueryService; private readonly IPublishStatusQueryService _publishStatusQueryService; private readonly IDomainCacheService _domainCacheService; + private readonly IDefaultCultureAccessor _defaultCultureAccessor; private readonly ConcurrentDictionary _cache = new(); private bool _isInitialized; @@ -96,6 +100,7 @@ public class DocumentUrlService : IDocumentUrlService /// /// Initializes a new instance of the class. /// + [Obsolete("Please use the constructor taking all parameters. Scheduled for removal in Umbraco 19.")] public DocumentUrlService( ILogger logger, IDocumentUrlRepository documentUrlRepository, @@ -111,12 +116,53 @@ public class DocumentUrlService : IDocumentUrlService IDocumentNavigationQueryService documentNavigationQueryService, IPublishStatusQueryService publishStatusQueryService, IDomainCacheService domainCacheService) + :this( + logger, + documentUrlRepository, + documentRepository, + coreScopeProvider, + globalSettings, + StaticServiceProvider.Instance.GetRequiredService>(), + urlSegmentProviderCollection, + contentService, + shortStringHelper, + languageService, + keyValueService, + idKeyMap, + documentNavigationQueryService, + publishStatusQueryService, + domainCacheService, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + /// + /// Initializes a new instance of the class. + /// + public DocumentUrlService( + ILogger logger, + IDocumentUrlRepository documentUrlRepository, + IDocumentRepository documentRepository, + ICoreScopeProvider coreScopeProvider, + IOptions globalSettings, + IOptions webRoutingSettings, + UrlSegmentProviderCollection urlSegmentProviderCollection, + IContentService contentService, + IShortStringHelper shortStringHelper, + ILanguageService languageService, + IKeyValueService keyValueService, + IIdKeyMap idKeyMap, + IDocumentNavigationQueryService documentNavigationQueryService, + IPublishStatusQueryService publishStatusQueryService, + IDomainCacheService domainCacheService, + IDefaultCultureAccessor defaultCultureAccessor) { _logger = logger; _documentUrlRepository = documentUrlRepository; _documentRepository = documentRepository; _coreScopeProvider = coreScopeProvider; _globalSettings = globalSettings.Value; + _webRoutingSettings = webRoutingSettings.Value; _urlSegmentProviderCollection = urlSegmentProviderCollection; _contentService = contentService; _shortStringHelper = shortStringHelper; @@ -126,6 +172,7 @@ public class DocumentUrlService : IDocumentUrlService _documentNavigationQueryService = documentNavigationQueryService; _publishStatusQueryService = publishStatusQueryService; _domainCacheService = domainCacheService; + _defaultCultureAccessor = defaultCultureAccessor; } /// @@ -494,6 +541,37 @@ public class DocumentUrlService : IDocumentUrlService scope.Complete(); } + /// + public Guid? GetDocumentKeyByUri(Uri uri, bool isDraft) + { + IEnumerable domains = _domainCacheService.GetAll(false); + DomainAndUri? domain = DomainUtilities.SelectDomain(domains, uri, defaultCulture: _defaultCultureAccessor.DefaultCulture); + + string route; + if (domain is not null) + { + route = domain.ContentId + DomainUtilities.PathRelativeToDomain(domain.Uri, uri.GetAbsolutePathDecoded()); + } + else + { + // If we have configured strict domain matching, and a domain has not been found for the request configured on an ancestor node, + // do not route the content by URL. + if (_webRoutingSettings.UseStrictDomainMatching) + { + return null; + } + + // Default behaviour if strict domain matching is not enabled will be to route under the to the first root node found. + route = uri.GetAbsolutePathDecoded(); + } + + return GetDocumentKeyByRoute( + domain is null ? route : route[domain.ContentId.ToString().Length..], + domain?.Culture, + domain?.ContentId, + isDraft); + } + /// public Guid? GetDocumentKeyByRoute(string route, string? culture, int? documentStartNodeId, bool isDraft) { diff --git a/src/Umbraco.Core/Services/IDocumentUrlService.cs b/src/Umbraco.Core/Services/IDocumentUrlService.cs index 8020a77269..eab37488df 100644 --- a/src/Umbraco.Core/Services/IDocumentUrlService.cs +++ b/src/Umbraco.Core/Services/IDocumentUrlService.cs @@ -1,5 +1,4 @@ using Umbraco.Cms.Core.Models; -using Umbraco.Cms.Core.Routing; namespace Umbraco.Cms.Core.Services; @@ -64,6 +63,14 @@ public interface IDocumentUrlService /// The collection of document keys. Task DeleteUrlsFromCacheAsync(IEnumerable documentKeys); + /// + /// Gets a document key by . + /// + /// The uniform resource identifier. + /// Whether to get the url of the draft or published document. + /// The document key, or null if not found. + Guid? GetDocumentKeyByUri(Uri uri, bool isDraft) => throw new NotImplementedException(); // TODO (V19): Remove default implementation. + /// /// Gets a document key by route. /// diff --git a/tests/Umbraco.Tests.Common/Builders/TemplateBuilder.cs b/tests/Umbraco.Tests.Common/Builders/TemplateBuilder.cs index 4c87e3ca64..a030b4c4a8 100644 --- a/tests/Umbraco.Tests.Common/Builders/TemplateBuilder.cs +++ b/tests/Umbraco.Tests.Common/Builders/TemplateBuilder.cs @@ -129,9 +129,9 @@ public class TemplateBuilder return template; } - public static Template CreateTextPageTemplate(string alias = "textPage") => + public static Template CreateTextPageTemplate(string alias = "textPage", string name = "Text page") => (Template)new TemplateBuilder() .WithAlias(alias) - .WithName("Text page") + .WithName(name) .Build(); } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs index 9c8b266bc5..a695a1bb10 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Services/DocumentUrlServiceTests.cs @@ -1,11 +1,13 @@ using NUnit.Framework; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Tests.Common.Builders; +using Umbraco.Cms.Tests.Common.Builders.Extensions; using Umbraco.Cms.Tests.Common.Testing; using Umbraco.Cms.Tests.Integration.Testing; using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Scoping; @@ -23,6 +25,8 @@ internal sealed class DocumentUrlServiceTests : UmbracoIntegrationTestWithConten protected ILanguageService LanguageService => GetRequiredService(); + protected IDomainService DomainService => GetRequiredService(); + protected override void CustomTestSetup(IUmbracoBuilder builder) { builder.Services.AddUnique(); @@ -148,6 +152,64 @@ internal sealed class DocumentUrlServiceTests : UmbracoIntegrationTestWithConten Assert.IsNull(actual); } + [TestCase("/", ExpectedResult = TextpageKey)] + [TestCase("/text-page-1", ExpectedResult = SubPageKey)] + public string? GetDocumentKeyByUri_Without_Domains_Returns_Expected_DocumentKey(string path) + { + ContentService.PublishBranch(Textpage, PublishBranchFilter.IncludeUnpublished, ["*"]); + + var uri = new Uri("http://example.com" + path); + return DocumentUrlService.GetDocumentKeyByUri(uri, false)?.ToString()?.ToUpper(); + } + + private const string VariantRootPageKey = "1D3283C7-64FD-4F4D-A741-442BDA487B71"; + private const string VariantChildPageKey = "1D3283C7-64FD-4F4D-A741-442BDA487B72"; + + [TestCase("/", "/en", "http://example.com/en/", ExpectedResult = VariantRootPageKey)] + [TestCase("/child-page", "/en", "http://example.com/en/", ExpectedResult = VariantChildPageKey)] + [TestCase("/", "example.com", "http://example.com/", ExpectedResult = VariantRootPageKey)] + [TestCase("/child-page", "example.com", "http://example.com/", ExpectedResult = VariantChildPageKey)] + public async Task GetDocumentKeyByUri_With_Domains_Returns_Expected_DocumentKey(string path, string domain, string rootUrl) + { + var template = TemplateBuilder.CreateTextPageTemplate("variantPageTemplate", "Variant Page Template"); + FileService.SaveTemplate(template); + + var contentType = new ContentTypeBuilder() + .WithAlias("variantPage") + .WithName("Variant Page") + .WithContentVariation(ContentVariation.Culture) + .WithAllowAsRoot(true) + .WithDefaultTemplateId(template.Id) + .Build(); + ContentTypeService.Save(contentType); + + var rootPage = new ContentBuilder() + .WithKey(Guid.Parse(VariantRootPageKey)) + .WithContentType(contentType) + .WithCultureName("en-US", $"Root Page") + .Build(); + var childPage = new ContentBuilder() + .WithKey(Guid.Parse(VariantChildPageKey)) + .WithContentType(contentType) + .WithCultureName("en-US", $"Child Page") + .WithParent(rootPage) + .Build(); + ContentService.Save(rootPage, -1); + ContentService.Save(childPage, -1); + ContentService.PublishBranch(rootPage, PublishBranchFilter.IncludeUnpublished, ["*"]); + + var updateDomainResult = await DomainService.UpdateDomainsAsync( + rootPage.Key, + new DomainsUpdateModel + { + Domains = [new DomainModel { DomainName = domain, IsoCode = "en-US" }], + }); + Assert.IsTrue(updateDomainResult.Success); + + var uri = new Uri(rootUrl + path); + return DocumentUrlService.GetDocumentKeyByUri(uri, false)?.ToString()?.ToUpper(); + } + [TestCase("/", "en-US", true, ExpectedResult = TextpageKey)] [TestCase("/text-page-1", "en-US", true, ExpectedResult = SubPageKey)] [TestCase("/text-page-1-custom", "en-US", true, ExpectedResult = SubPageKey)] // Uses the segment registered by the custom IIUrlSegmentProvider that allows for more than one segment per document. @@ -160,7 +222,7 @@ internal sealed class DocumentUrlServiceTests : UmbracoIntegrationTestWithConten [TestCase("/text-page-2", "en-US", false, ExpectedResult = null)] [TestCase("/text-page-2-custom", "en-US", false, ExpectedResult = SubPage2Key)] // Uses the segment registered by the custom IIUrlSegmentProvider that does not allow for more than one segment per document. [TestCase("/text-page-3", "en-US", false, ExpectedResult = SubPage3Key)] - public string? GetDocumentKeyByRoute_Returns_Expected_Route(string route, string isoCode, bool loadDraft) + public string? GetDocumentKeyByRoute_Returns_Expected_DocumentKey(string route, string isoCode, bool loadDraft) { if (loadDraft is false) {