Skip to content

Commit

Permalink
Introduce path provider and resolver for the Content Delivery API (#1…
Browse files Browse the repository at this point in the history
  • Loading branch information
kjac committed Mar 22, 2024
1 parent b7533b5 commit b1c3473
Show file tree
Hide file tree
Showing 15 changed files with 188 additions and 33 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@
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;

[ApiVersion("1.0")]
[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(
Expand Down Expand Up @@ -58,20 +58,58 @@ 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,
IRequestRoutingService requestRoutingService,
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}")]
Expand Down Expand Up @@ -105,8 +143,6 @@ public async Task<IActionResult> ByRouteV20(string path = "")
private async Task<IActionResult> HandleRequest(string path)
{
path = DecodePath(path);

path = path.TrimStart("/");
path = path.Length == 0 ? "/" : path;

IPublishedContent? contentItem = GetContent(path);
Expand All @@ -128,17 +164,12 @@ private async Task<IActionResult> HandleRequest(string path)
}

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)
{
Expand All @@ -147,7 +178,7 @@ private async Task<IActionResult> HandleRequest(string path)
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;
}
Expand Down
16 changes: 16 additions & 0 deletions src/Umbraco.Core/DeliveryApi/ApiContentPathProvider.cs
Original file line number Diff line number Diff line change
@@ -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);
}
26 changes: 26 additions & 0 deletions src/Umbraco.Core/DeliveryApi/ApiContentPathResolver.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
37 changes: 32 additions & 5 deletions src/Umbraco.Core/DeliveryApi/ApiContentRouteBuilder.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -10,22 +12,47 @@ 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,
IVariationContextAccessor variationContextAccessor,
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;
Expand Down Expand Up @@ -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,
Expand All @@ -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)
{
Expand Down
8 changes: 8 additions & 0 deletions src/Umbraco.Core/DeliveryApi/IApiContentPathProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Umbraco.Cms.Core.Models.PublishedContent;

namespace Umbraco.Cms.Core.DeliveryApi;

public interface IApiContentPathProvider
{
string? GetContentPath(IPublishedContent content, string? culture);
}
8 changes: 8 additions & 0 deletions src/Umbraco.Core/DeliveryApi/IApiContentPathResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using Umbraco.Cms.Core.Models.PublishedContent;

namespace Umbraco.Cms.Core.DeliveryApi;

public interface IApiContentPathResolver
{
IPublishedContent? ResolveContentPath(string path);
}
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,8 @@ private static IUmbracoBuilder AddDeliveryApiCoreServices(this IUmbracoBuilder b
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>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ public void ContentBuilder_MapsContentDataAndPropertiesCorrectly()
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ private ContentPickerValueConverter CreateValueConverter(IApiContentNameProvider
PublishedSnapshotAccessor,
new ApiContentBuilder(
nameProvider ?? new ApiContentNameProvider(),
CreateContentRouteBuilder(PublishedUrlProvider, CreateGlobalSettings()),
CreateContentRouteBuilder(ApiContentPathProvider, CreateGlobalSettings()),
CreateOutputExpansionStrategyAccessor()));

[Test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,34 @@ public void CanRoutePublishedChildOfUnpublishedParentInPreview(bool isPreview)
}
}

[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();
Expand Down Expand Up @@ -310,7 +338,10 @@ string Url(IPublishedContent content, string? culture)
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>>();
Expand All @@ -320,9 +351,10 @@ private ApiContentRouteBuilder CreateApiContentRouteBuilder(bool hideTopLevelNod
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,
Expand All @@ -335,12 +367,13 @@ private ApiContentRouteBuilder CreateApiContentRouteBuilder(bool hideTopLevelNod
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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ protected string DefaultUrlSegment(string name, string? culture = null)
=> $"{name.ToLowerInvariant().Replace(" ", "-")}{(culture.IsNullOrWhiteSpace() ? string.Empty : $"-{culture}")}";

protected ApiContentRouteBuilder CreateContentRouteBuilder(
IPublishedUrlProvider publishedUrlProvider,
IApiContentPathProvider contentPathProvider,
IOptions<GlobalSettings> globalSettings,
IVariationContextAccessor? variationContextAccessor = null,
IPublishedSnapshotAccessor? publishedSnapshotAccessor = null,
Expand All @@ -129,7 +129,7 @@ protected string DefaultUrlSegment(string name, string? culture = null)
}

return new ApiContentRouteBuilder(
publishedUrlProvider,
contentPathProvider,
globalSettings,
variationContextAccessor ?? Mock.Of<IVariationContextAccessor>(),
publishedSnapshotAccessor ?? Mock.Of<IPublishedSnapshotAccessor>(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ private MultiNodeTreePickerValueConverter MultiNodeTreePickerValueConverter(IApi

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>(),
Expand Down
Loading

0 comments on commit b1c3473

Please sign in to comment.