Introduce path provider and resolver for the Content Delivery API (#15922)

This commit is contained in:
Kenn Jacobsen
2024-03-22 14:12:33 +01:00
committed by GitHub
parent b7533b57b4
commit b1c3473856
15 changed files with 188 additions and 33 deletions

View File

@@ -8,7 +8,6 @@ using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Services;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Delivery.Controllers.Content;
@@ -16,10 +15,11 @@ namespace Umbraco.Cms.Api.Delivery.Controllers.Content;
[ApiVersion("2.0")]
public class ByRouteContentApiController : ContentApiItemControllerBase
{
private readonly IRequestRoutingService _requestRoutingService;
private readonly IApiContentPathResolver _apiContentPathResolver;
private readonly IRequestRedirectService _requestRedirectService;
private readonly IRequestPreviewService _requestPreviewService;
private readonly IRequestMemberAccessService _requestMemberAccessService;
private const string PreviewContentRequestPathPrefix = $"/{Constants.DeliveryApi.Routing.PreviewContentPathPrefix}";
[Obsolete($"Please use the constructor that does not accept {nameof(IPublicAccessService)}. Will be removed in V14.")]
public ByRouteContentApiController(
@@ -58,7 +58,7 @@ public class ByRouteContentApiController : ContentApiItemControllerBase
{
}
[ActivatorUtilitiesConstructor]
[Obsolete($"Please use the constructor that accepts {nameof(IApiContentPathResolver)}. Will be removed in V15.")]
public ByRouteContentApiController(
IApiPublishedContentCache apiPublishedContentCache,
IApiContentResponseBuilder apiContentResponseBuilder,
@@ -66,12 +66,50 @@ public class ByRouteContentApiController : ContentApiItemControllerBase
IRequestRedirectService requestRedirectService,
IRequestPreviewService requestPreviewService,
IRequestMemberAccessService requestMemberAccessService)
: this(
apiPublishedContentCache,
apiContentResponseBuilder,
requestRedirectService,
requestPreviewService,
requestMemberAccessService,
StaticServiceProvider.Instance.GetRequiredService<IApiContentPathResolver>())
{
}
[Obsolete($"Please use the non-obsolete constructor. Will be removed in V15.")]
public ByRouteContentApiController(
IApiPublishedContentCache apiPublishedContentCache,
IApiContentResponseBuilder apiContentResponseBuilder,
IPublicAccessService publicAccessService,
IRequestRoutingService requestRoutingService,
IRequestRedirectService requestRedirectService,
IRequestPreviewService requestPreviewService,
IRequestMemberAccessService requestMemberAccessService,
IApiContentPathResolver apiContentPathResolver)
: this(
apiPublishedContentCache,
apiContentResponseBuilder,
requestRedirectService,
requestPreviewService,
requestMemberAccessService,
apiContentPathResolver)
{
}
[ActivatorUtilitiesConstructor]
public ByRouteContentApiController(
IApiPublishedContentCache apiPublishedContentCache,
IApiContentResponseBuilder apiContentResponseBuilder,
IRequestRedirectService requestRedirectService,
IRequestPreviewService requestPreviewService,
IRequestMemberAccessService requestMemberAccessService,
IApiContentPathResolver apiContentPathResolver)
: base(apiPublishedContentCache, apiContentResponseBuilder)
{
_requestRoutingService = requestRoutingService;
_requestRedirectService = requestRedirectService;
_requestPreviewService = requestPreviewService;
_requestMemberAccessService = requestMemberAccessService;
_apiContentPathResolver = apiContentPathResolver;
}
[HttpGet("item/{*path}")]
@@ -105,8 +143,6 @@ public class ByRouteContentApiController : ContentApiItemControllerBase
private async Task<IActionResult> HandleRequest(string path)
{
path = DecodePath(path);
path = path.TrimStart("/");
path = path.Length == 0 ? "/" : path;
IPublishedContent? contentItem = GetContent(path);
@@ -128,17 +164,12 @@ public class ByRouteContentApiController : ContentApiItemControllerBase
}
private IPublishedContent? GetContent(string path)
=> path.StartsWith(Constants.DeliveryApi.Routing.PreviewContentPathPrefix)
=> path.StartsWith(PreviewContentRequestPathPrefix)
? GetPreviewContent(path)
: GetPublishedContent(path);
private IPublishedContent? GetPublishedContent(string path)
{
var contentRoute = _requestRoutingService.GetContentRoute(path);
IPublishedContent? contentItem = ApiPublishedContentCache.GetByRoute(contentRoute);
return contentItem;
}
=> _apiContentPathResolver.ResolveContentPath(path);
private IPublishedContent? GetPreviewContent(string path)
{
@@ -147,7 +178,7 @@ public class ByRouteContentApiController : ContentApiItemControllerBase
return null;
}
if (Guid.TryParse(path.AsSpan(Constants.DeliveryApi.Routing.PreviewContentPathPrefix.Length).TrimEnd("/"), out Guid contentId) is false)
if (Guid.TryParse(path.AsSpan(PreviewContentRequestPathPrefix.Length).TrimEnd("/"), out Guid contentId) is false)
{
return null;
}

View File

@@ -0,0 +1,16 @@
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.Routing;
namespace Umbraco.Cms.Core.DeliveryApi;
// NOTE: left unsealed on purpose so it is extendable.
public class ApiContentPathProvider : IApiContentPathProvider
{
private readonly IPublishedUrlProvider _publishedUrlProvider;
public ApiContentPathProvider(IPublishedUrlProvider publishedUrlProvider)
=> _publishedUrlProvider = publishedUrlProvider;
public virtual string? GetContentPath(IPublishedContent content, string? culture)
=> _publishedUrlProvider.GetUrl(content, UrlMode.Relative, culture);
}

View File

@@ -0,0 +1,26 @@
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Extensions;
namespace Umbraco.Cms.Core.DeliveryApi;
// NOTE: left unsealed on purpose so it is extendable.
public class ApiContentPathResolver : IApiContentPathResolver
{
private readonly IRequestRoutingService _requestRoutingService;
private readonly IApiPublishedContentCache _apiPublishedContentCache;
public ApiContentPathResolver(IRequestRoutingService requestRoutingService, IApiPublishedContentCache apiPublishedContentCache)
{
_requestRoutingService = requestRoutingService;
_apiPublishedContentCache = apiPublishedContentCache;
}
public virtual IPublishedContent? ResolveContentPath(string path)
{
path = path.EnsureStartsWith("/");
var contentRoute = _requestRoutingService.GetContentRoute(path);
IPublishedContent? contentItem = _apiPublishedContentCache.GetByRoute(contentRoute);
return contentItem;
}
}

View File

@@ -1,5 +1,7 @@
using Microsoft.Extensions.Options;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.DependencyInjection;
using Umbraco.Cms.Core.Models.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
@@ -10,13 +12,14 @@ namespace Umbraco.Cms.Core.DeliveryApi;
public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder
{
private readonly IPublishedUrlProvider _publishedUrlProvider;
private readonly IApiContentPathProvider _apiContentPathProvider;
private readonly GlobalSettings _globalSettings;
private readonly IVariationContextAccessor _variationContextAccessor;
private readonly IPublishedSnapshotAccessor _publishedSnapshotAccessor;
private readonly IRequestPreviewService _requestPreviewService;
private RequestHandlerSettings _requestSettings;
[Obsolete($"Use the constructor that does not accept {nameof(IPublishedUrlProvider)}. Will be removed in V15.")]
public ApiContentRouteBuilder(
IPublishedUrlProvider publishedUrlProvider,
IOptions<GlobalSettings> globalSettings,
@@ -24,8 +27,32 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder
IPublishedSnapshotAccessor publishedSnapshotAccessor,
IRequestPreviewService requestPreviewService,
IOptionsMonitor<RequestHandlerSettings> requestSettings)
: this(StaticServiceProvider.Instance.GetRequiredService<IApiContentPathProvider>(), globalSettings, variationContextAccessor, publishedSnapshotAccessor, requestPreviewService, requestSettings)
{
_publishedUrlProvider = publishedUrlProvider;
}
[Obsolete($"Use the constructor that does not accept {nameof(IPublishedUrlProvider)}. Will be removed in V15.")]
public ApiContentRouteBuilder(
IPublishedUrlProvider publishedUrlProvider,
IApiContentPathProvider apiContentPathProvider,
IOptions<GlobalSettings> globalSettings,
IVariationContextAccessor variationContextAccessor,
IPublishedSnapshotAccessor publishedSnapshotAccessor,
IRequestPreviewService requestPreviewService,
IOptionsMonitor<RequestHandlerSettings> requestSettings)
: this(apiContentPathProvider, globalSettings, variationContextAccessor, publishedSnapshotAccessor, requestPreviewService, requestSettings)
{
}
public ApiContentRouteBuilder(
IApiContentPathProvider apiContentPathProvider,
IOptions<GlobalSettings> globalSettings,
IVariationContextAccessor variationContextAccessor,
IPublishedSnapshotAccessor publishedSnapshotAccessor,
IRequestPreviewService requestPreviewService,
IOptionsMonitor<RequestHandlerSettings> requestSettings)
{
_apiContentPathProvider = apiContentPathProvider;
_variationContextAccessor = variationContextAccessor;
_publishedSnapshotAccessor = publishedSnapshotAccessor;
_requestPreviewService = requestPreviewService;
@@ -72,7 +99,7 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder
}
// grab the content path from the URL provider
var contentPath = _publishedUrlProvider.GetUrl(content, UrlMode.Relative, culture);
var contentPath = _apiContentPathProvider.GetContentPath(content, culture);
// in some scenarios the published content is actually routable, but due to the built-in handling of i.e. lacking culture setup
// the URL provider resolves the content URL as empty string or "#". since the Delivery API handles routing explicitly,
@@ -96,7 +123,7 @@ public sealed class ApiContentRouteBuilder : IApiContentRouteBuilder
private string ContentPreviewPath(IPublishedContent content) => $"{Constants.DeliveryApi.Routing.PreviewContentPathPrefix}{content.Key:D}{(_requestSettings.AddTrailingSlash ? "/" : string.Empty)}";
private static bool IsInvalidContentPath(string path) => path.IsNullOrWhiteSpace() || "#".Equals(path);
private static bool IsInvalidContentPath(string? path) => path.IsNullOrWhiteSpace() || "#".Equals(path);
private IPublishedContent GetRoot(IPublishedContent content, bool isPreview)
{

View File

@@ -0,0 +1,8 @@
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Core.DeliveryApi;
public interface IApiContentPathProvider
{
string? GetContentPath(IPublishedContent content, string? culture);
}

View File

@@ -0,0 +1,8 @@
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Core.DeliveryApi;
public interface IApiContentPathResolver
{
IPublishedContent? ResolveContentPath(string path);
}

View File

@@ -453,6 +453,8 @@ public static partial class UmbracoBuilderExtensions
builder.Services.AddSingleton<IApiMediaQueryService, NoopApiMediaQueryService>();
builder.Services.AddSingleton<IApiMediaUrlProvider, ApiMediaUrlProvider>();
builder.Services.AddSingleton<IApiContentRouteBuilder, ApiContentRouteBuilder>();
builder.Services.AddSingleton<IApiContentPathProvider, ApiContentPathProvider>();
builder.Services.AddSingleton<IApiContentPathResolver, ApiContentPathResolver>();
builder.Services.AddSingleton<IApiPublishedContentCache, ApiPublishedContentCache>();
builder.Services.AddSingleton<IApiRichTextElementParser, ApiRichTextElementParser>();
builder.Services.AddSingleton<IApiRichTextMarkupParser, ApiRichTextMarkupParser>();

View File

@@ -31,12 +31,12 @@ public class ContentBuilderTests : DeliveryApiTests
content.SetupGet(c => c.CreateDate).Returns(new DateTime(2023, 06, 01));
content.SetupGet(c => c.UpdateDate).Returns(new DateTime(2023, 07, 12));
var publishedUrlProvider = new Mock<IPublishedUrlProvider>();
publishedUrlProvider
.Setup(p => p.GetUrl(It.IsAny<IPublishedContent>(), It.IsAny<UrlMode>(), It.IsAny<string?>(), It.IsAny<Uri?>()))
.Returns((IPublishedContent content, UrlMode mode, string? culture, Uri? current) => $"url:{content.UrlSegment}");
var apiContentRouteProvider = new Mock<IApiContentPathProvider>();
apiContentRouteProvider
.Setup(p => p.GetContentPath(It.IsAny<IPublishedContent>(), It.IsAny<string?>()))
.Returns((IPublishedContent c, string? culture) => $"url:{c.UrlSegment}");
var routeBuilder = CreateContentRouteBuilder(publishedUrlProvider.Object, CreateGlobalSettings());
var routeBuilder = CreateContentRouteBuilder(apiContentRouteProvider.Object, CreateGlobalSettings());
var builder = new ApiContentBuilder(new ApiContentNameProvider(), routeBuilder, CreateOutputExpansionStrategyAccessor());
var result = builder.Build(content.Object);

View File

@@ -18,7 +18,7 @@ public class ContentPickerValueConverterTests : PropertyValueConverterTests
PublishedSnapshotAccessor,
new ApiContentBuilder(
nameProvider ?? new ApiContentNameProvider(),
CreateContentRouteBuilder(PublishedUrlProvider, CreateGlobalSettings()),
CreateContentRouteBuilder(ApiContentPathProvider, CreateGlobalSettings()),
CreateOutputExpansionStrategyAccessor()));
[Test]

View File

@@ -254,6 +254,34 @@ public class ContentRouteBuilderTests : DeliveryApiTests
}
}
[Test]
public void CanUseCustomContentPathProvider()
{
var rootKey = Guid.NewGuid();
var root = SetupInvariantPublishedContent("The Root", rootKey, published: false);
var childKey = Guid.NewGuid();
var child = SetupInvariantPublishedContent("The Child", childKey, root);
var apiContentPathProvider = new Mock<IApiContentPathProvider>();
apiContentPathProvider
.Setup(p => p.GetContentPath(It.IsAny<IPublishedContent>(), It.IsAny<string?>()))
.Returns((IPublishedContent content, string? culture) => $"my-custom-path-for-{content.UrlSegment}");
var builder = CreateApiContentRouteBuilder(true, apiContentPathProvider: apiContentPathProvider.Object);
var result = builder.Build(root);
Assert.NotNull(result);
Assert.AreEqual("/my-custom-path-for-the-root", result.Path);
Assert.AreEqual(rootKey, result.StartItem.Id);
Assert.AreEqual("the-root", result.StartItem.Path);
result = builder.Build(child);
Assert.NotNull(result);
Assert.AreEqual("/my-custom-path-for-the-child", result.Path);
Assert.AreEqual(rootKey, result.StartItem.Id);
Assert.AreEqual("the-root", result.StartItem.Path);
}
private IPublishedContent SetupInvariantPublishedContent(string name, Guid key, IPublishedContent? parent = null, bool published = true)
{
var publishedContentType = CreatePublishedContentType();
@@ -310,7 +338,10 @@ public class ContentRouteBuilderTests : DeliveryApiTests
return publishedUrlProvider.Object;
}
private ApiContentRouteBuilder CreateApiContentRouteBuilder(bool hideTopLevelNodeFromPath, bool addTrailingSlash = false, bool isPreview = false, IPublishedSnapshotAccessor? publishedSnapshotAccessor = null)
private IApiContentPathProvider SetupApiContentPathProvider(bool hideTopLevelNodeFromPath)
=> new ApiContentPathProvider(SetupPublishedUrlProvider(hideTopLevelNodeFromPath));
private ApiContentRouteBuilder CreateApiContentRouteBuilder(bool hideTopLevelNodeFromPath, bool addTrailingSlash = false, bool isPreview = false, IPublishedSnapshotAccessor? publishedSnapshotAccessor = null, IApiContentPathProvider? apiContentPathProvider = null)
{
var requestHandlerSettings = new RequestHandlerSettings { AddTrailingSlash = addTrailingSlash };
var requestHandlerSettingsMonitorMock = new Mock<IOptionsMonitor<RequestHandlerSettings>>();
@@ -320,9 +351,10 @@ public class ContentRouteBuilderTests : DeliveryApiTests
requestPreviewServiceMock.Setup(m => m.IsPreview()).Returns(isPreview);
publishedSnapshotAccessor ??= CreatePublishedSnapshotAccessorForRoute("#");
apiContentPathProvider ??= SetupApiContentPathProvider(hideTopLevelNodeFromPath);
return CreateContentRouteBuilder(
SetupPublishedUrlProvider(hideTopLevelNodeFromPath),
apiContentPathProvider,
CreateGlobalSettings(hideTopLevelNodeFromPath),
requestHandlerSettingsMonitor: requestHandlerSettingsMonitorMock.Object,
requestPreviewService: requestPreviewServiceMock.Object,
@@ -335,12 +367,13 @@ public class ContentRouteBuilderTests : DeliveryApiTests
publishedUrlProviderMock
.Setup(p => p.GetUrl(It.IsAny<IPublishedContent>(), It.IsAny<UrlMode>(), It.IsAny<string?>(), It.IsAny<Uri?>()))
.Returns(publishedUrl);
var contentPathProvider = new ApiContentPathProvider(publishedUrlProviderMock.Object);
var publishedSnapshotAccessor = CreatePublishedSnapshotAccessorForRoute(routeById);
var content = SetupVariantPublishedContent("The Content", Guid.NewGuid());
var builder = CreateContentRouteBuilder(
publishedUrlProviderMock.Object,
contentPathProvider,
CreateGlobalSettings(),
publishedSnapshotAccessor: publishedSnapshotAccessor);

View File

@@ -114,7 +114,7 @@ public class DeliveryApiTests
=> $"{name.ToLowerInvariant().Replace(" ", "-")}{(culture.IsNullOrWhiteSpace() ? string.Empty : $"-{culture}")}";
protected ApiContentRouteBuilder CreateContentRouteBuilder(
IPublishedUrlProvider publishedUrlProvider,
IApiContentPathProvider contentPathProvider,
IOptions<GlobalSettings> globalSettings,
IVariationContextAccessor? variationContextAccessor = null,
IPublishedSnapshotAccessor? publishedSnapshotAccessor = null,
@@ -129,7 +129,7 @@ public class DeliveryApiTests
}
return new ApiContentRouteBuilder(
publishedUrlProvider,
contentPathProvider,
globalSettings,
variationContextAccessor ?? Mock.Of<IVariationContextAccessor>(),
publishedSnapshotAccessor ?? Mock.Of<IPublishedSnapshotAccessor>(),

View File

@@ -21,7 +21,7 @@ public class MultiNodeTreePickerValueConverterTests : PropertyValueConverterTest
var contentNameProvider = new ApiContentNameProvider();
var apiUrProvider = new ApiMediaUrlProvider(PublishedUrlProvider);
routeBuilder = routeBuilder ?? CreateContentRouteBuilder(PublishedUrlProvider, CreateGlobalSettings());
routeBuilder = routeBuilder ?? CreateContentRouteBuilder(ApiContentPathProvider, CreateGlobalSettings());
return new MultiNodeTreePickerValueConverter(
PublishedSnapshotAccessor,
Mock.Of<IUmbracoContextAccessor>(),

View File

@@ -294,7 +294,7 @@ public class MultiUrlPickerValueConverterTests : PropertyValueConverterTests
private MultiUrlPickerValueConverter MultiUrlPickerValueConverter()
{
var routeBuilder = CreateContentRouteBuilder(PublishedUrlProvider, CreateGlobalSettings());
var routeBuilder = CreateContentRouteBuilder(ApiContentPathProvider, CreateGlobalSettings());
return new MultiUrlPickerValueConverter(
PublishedSnapshotAccessor,
Mock.Of<IProfilingLogger>(),

View File

@@ -452,5 +452,5 @@ public abstract class OutputExpansionStrategyTestBase : PropertyValueConverterTe
return new PublishedElementPropertyBase(elementPropertyType, parent, false, PropertyCacheLevel.None);
}
protected IApiContentRouteBuilder ApiContentRouteBuilder() => CreateContentRouteBuilder(PublishedUrlProvider, CreateGlobalSettings());
protected IApiContentRouteBuilder ApiContentRouteBuilder() => CreateContentRouteBuilder(ApiContentPathProvider, CreateGlobalSettings());
}

View File

@@ -1,5 +1,6 @@
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
@@ -12,6 +13,8 @@ public class PropertyValueConverterTests : DeliveryApiTests
protected IPublishedUrlProvider PublishedUrlProvider { get; private set; }
protected IApiContentPathProvider ApiContentPathProvider { get; private set; }
protected IPublishedContent PublishedContent { get; private set; }
protected IPublishedContent PublishedMedia { get; private set; }
@@ -69,6 +72,7 @@ public class PropertyValueConverterTests : DeliveryApiTests
.Setup(p => p.GetMediaUrl(publishedMedia.Object, It.IsAny<UrlMode>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<Uri?>()))
.Returns("the-media-url");
PublishedUrlProvider = PublishedUrlProviderMock.Object;
ApiContentPathProvider = new ApiContentPathProvider(PublishedUrlProvider);
var publishedSnapshotAccessor = new Mock<IPublishedSnapshotAccessor>();
var publishedSnapshotObject = publishedSnapshot.Object;