Segment support for the Delivery API (#19082)

* Segment support for the Delivery API

* Do not apply empty strings as variance.

---------

Co-authored-by: Niels Lyngsø <nsl@umbraco.dk>
This commit is contained in:
Kenn Jacobsen
2025-04-23 07:45:04 +02:00
committed by GitHub
parent c42237ecfa
commit 64f1e17479
12 changed files with 411 additions and 60 deletions

View File

@@ -13,7 +13,7 @@ namespace Umbraco.Cms.Api.Delivery.Controllers.Content;
[DeliveryApiAccess]
[VersionedDeliveryApiRoute("content")]
[ApiExplorerSettings(GroupName = "Content")]
[LocalizeFromAcceptLanguageHeader]
[ContextualizeFromAcceptHeaders]
[ValidateStartItem]
[AddVaryHeader]
[OutputCache(PolicyName = Constants.DeliveryApi.OutputCache.ContentCachePolicy)]

View File

@@ -49,6 +49,7 @@ public static class UmbracoBuilderExtensions
: provider.GetRequiredService<RequestContextOutputExpansionStrategyV2>();
});
builder.Services.AddSingleton<IRequestCultureService, RequestCultureService>();
builder.Services.AddSingleton<IRequestSegmmentService, RequestSegmentService>();
builder.Services.AddSingleton<IRequestRoutingService, RequestRoutingService>();
builder.Services.AddSingleton<IRequestRedirectService, RequestRedirectService>();
builder.Services.AddSingleton<IRequestPreviewService, RequestPreviewService>();

View File

@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Delivery.Filters;
internal sealed class ContextualizeFromAcceptHeadersAttribute : TypeFilterAttribute
{
public ContextualizeFromAcceptHeadersAttribute()
: base(typeof(LocalizeFromAcceptLanguageHeaderAttributeFilter))
{
}
private class LocalizeFromAcceptLanguageHeaderAttributeFilter : IActionFilter
{
private readonly IRequestCultureService _requestCultureService;
private readonly IRequestSegmmentService _requestSegmentService;
private readonly IVariationContextAccessor _variationContextAccessor;
public LocalizeFromAcceptLanguageHeaderAttributeFilter(
IRequestCultureService requestCultureService,
IRequestSegmmentService requestSegmentService,
IVariationContextAccessor variationContextAccessor)
{
_requestCultureService = requestCultureService;
_requestSegmentService = requestSegmentService;
_variationContextAccessor = variationContextAccessor;
}
public void OnActionExecuting(ActionExecutingContext context)
{
var requestedCulture = _requestCultureService.GetRequestedCulture().NullOrWhiteSpaceAsNull();
var requestedSegment = _requestSegmentService.GetRequestedSegment().NullOrWhiteSpaceAsNull();
if (requestedCulture.IsNullOrWhiteSpace() && requestedSegment.IsNullOrWhiteSpace())
{
return;
}
// contextualize the request
// NOTE: request culture or segment can be null here (but not both), so make sure to retain any existing
// context by means of fallback to current variation context (if available)
_variationContextAccessor.VariationContext = new VariationContext(
requestedCulture ?? _variationContextAccessor.VariationContext?.Culture,
requestedSegment ?? _variationContextAccessor.VariationContext?.Segment);
}
public void OnActionExecuted(ActionExecutedContext context)
{
}
}
}

View File

@@ -1,37 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Extensions;
namespace Umbraco.Cms.Api.Delivery.Filters;
internal sealed class LocalizeFromAcceptLanguageHeaderAttribute : TypeFilterAttribute
{
public LocalizeFromAcceptLanguageHeaderAttribute()
: base(typeof(LocalizeFromAcceptLanguageHeaderAttributeFilter))
{
}
private class LocalizeFromAcceptLanguageHeaderAttributeFilter : IActionFilter
{
private readonly IRequestCultureService _requestCultureService;
public LocalizeFromAcceptLanguageHeaderAttributeFilter(IRequestCultureService requestCultureService)
=> _requestCultureService = requestCultureService;
public void OnActionExecuting(ActionExecutingContext context)
{
var requestedCulture = _requestCultureService.GetRequestedCulture();
if (requestedCulture.IsNullOrWhiteSpace())
{
return;
}
_requestCultureService.SetRequestCulture(requestedCulture);
}
public void OnActionExecuted(ActionExecutedContext context)
{
}
}
}

View File

@@ -33,6 +33,20 @@ internal sealed class SwaggerContentDocumentationFilter : SwaggerDocumentationFi
}
});
operation.Parameters.Add(new OpenApiParameter
{
Name = "Accept-Segment",
In = ParameterLocation.Header,
Required = false,
Description = "Defines the segment to return. Use this when querying segment variant content items.",
Schema = new OpenApiSchema { Type = "string" },
Examples = new Dictionary<string, OpenApiExample>
{
{ "Default", new OpenApiExample { Value = new OpenApiString(string.Empty) } },
{ "Segment One", new OpenApiExample { Value = new OpenApiString("segment-one") } }
}
});
AddApiKey(operation);
operation.Parameters.Add(new OpenApiParameter

View File

@@ -2,17 +2,15 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Net.Http.Headers;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
namespace Umbraco.Cms.Api.Delivery.Services;
internal sealed partial class RequestCultureService : RequestHeaderHandler, IRequestCultureService
{
private readonly IVariationContextAccessor _variationContextAccessor;
public RequestCultureService(IHttpContextAccessor httpContextAccessor, IVariationContextAccessor variationContextAccessor)
: base(httpContextAccessor) =>
_variationContextAccessor = variationContextAccessor;
public RequestCultureService(IHttpContextAccessor httpContextAccessor)
: base(httpContextAccessor)
{
}
/// <inheritdoc />
public string? GetRequestedCulture()
@@ -21,15 +19,10 @@ internal sealed partial class RequestCultureService : RequestHeaderHandler, IReq
return ValidLanguageHeaderRegex().IsMatch(acceptLanguage) ? acceptLanguage : null;
}
/// <inheritdoc />
[Obsolete("Use IVariationContextAccessor to manipulate the variation context. Scheduled for removal in V17.")]
public void SetRequestCulture(string culture)
{
if (_variationContextAccessor.VariationContext?.Culture == culture)
{
return;
}
_variationContextAccessor.VariationContext = new VariationContext(culture);
// no-op
}
// at the time of writing we're introducing this to get rid of accept-language header values like "en-GB,en-US;q=0.9,en;q=0.8",

View File

@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Http;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
@@ -11,14 +10,19 @@ namespace Umbraco.Cms.Api.Delivery.Services;
internal sealed class RequestRoutingService : RoutingServiceBase, IRequestRoutingService
{
private readonly IRequestCultureService _requestCultureService;
private readonly IVariationContextAccessor _variationContextAccessor;
public RequestRoutingService(
IDomainCache domainCache,
IHttpContextAccessor httpContextAccessor,
IRequestStartItemProviderAccessor requestStartItemProviderAccessor,
IRequestCultureService requestCultureService)
: base(domainCache, httpContextAccessor, requestStartItemProviderAccessor) =>
IRequestCultureService requestCultureService,
IVariationContextAccessor variationContextAccessor)
: base(domainCache, httpContextAccessor, requestStartItemProviderAccessor)
{
_requestCultureService = requestCultureService;
_variationContextAccessor = variationContextAccessor;
}
/// <inheritdoc />
public string GetContentRoute(string requestedPath)
@@ -50,9 +54,14 @@ internal sealed class RequestRoutingService : RoutingServiceBase, IRequestRoutin
}
// the Accept-Language header takes precedence over configured domain culture
if (domainAndUri.Culture != null && _requestCultureService.GetRequestedCulture().IsNullOrWhiteSpace())
if (domainAndUri.Culture != null
&& _requestCultureService.GetRequestedCulture().IsNullOrWhiteSpace()
&& _variationContextAccessor.VariationContext?.Culture != domainAndUri.Culture)
{
_requestCultureService.SetRequestCulture(domainAndUri.Culture);
// update the variation context to match the requested domain culture while retaining any contextualized segment
_variationContextAccessor.VariationContext = new VariationContext(
culture: domainAndUri.Culture,
segment: _variationContextAccessor.VariationContext?.Segment);
}
// when resolving content from a configured domain, the content cache expects the content route

View File

@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Http;
using Umbraco.Cms.Core.DeliveryApi;
namespace Umbraco.Cms.Api.Delivery.Services;
internal sealed class RequestSegmentService : RequestHeaderHandler, IRequestSegmmentService
{
public RequestSegmentService(IHttpContextAccessor httpContextAccessor)
: base(httpContextAccessor)
{
}
/// <inheritdoc />
public string? GetRequestedSegment()
=> GetHeaderValue("Accept-Segment");
}

View File

@@ -7,9 +7,6 @@ public interface IRequestCultureService
/// </summary>
string? GetRequestedCulture();
/// <summary>
/// Updates the current request culture if applicable.
/// </summary>
/// <param name="culture">The culture to use for the current request.</param>
[Obsolete("Use IVariationContextAccessor to manipulate the variation context. Scheduled for removal in V17.")]
void SetRequestCulture(string culture);
}

View File

@@ -0,0 +1,9 @@
namespace Umbraco.Cms.Core.DeliveryApi;
public interface IRequestSegmmentService
{
/// <summary>
/// Gets the requested segment from the "Accept-Segment" header, if present.
/// </summary>
string? GetRequestedSegment();
}

View File

@@ -249,6 +249,22 @@ internal sealed class OpenApiContractTest : UmbracoTestServerTestBase
}
}
},
{
"name": "Accept-Segment",
"in": "header",
"description": "Defines the segment to return. Use this when querying segment variant content items.",
"schema": {
"type": "string"
},
"examples": {
"Default": {
"value": ""
},
"Segment One": {
"value": "segment-one"
}
}
},
{
"name": "Api-Key",
"in": "header",
@@ -388,6 +404,22 @@ internal sealed class OpenApiContractTest : UmbracoTestServerTestBase
}
}
},
{
"name": "Accept-Segment",
"in": "header",
"description": "Defines the segment to return. Use this when querying segment variant content items.",
"schema": {
"type": "string"
},
"examples": {
"Default": {
"value": ""
},
"Segment One": {
"value": "segment-one"
}
}
},
{
"name": "Api-Key",
"in": "header",
@@ -519,6 +551,22 @@ internal sealed class OpenApiContractTest : UmbracoTestServerTestBase
}
}
},
{
"name": "Accept-Segment",
"in": "header",
"description": "Defines the segment to return. Use this when querying segment variant content items.",
"schema": {
"type": "string"
},
"examples": {
"Default": {
"value": ""
},
"Segment One": {
"value": "segment-one"
}
}
},
{
"name": "Api-Key",
"in": "header",
@@ -653,6 +701,22 @@ internal sealed class OpenApiContractTest : UmbracoTestServerTestBase
}
}
},
{
"name": "Accept-Segment",
"in": "header",
"description": "Defines the segment to return. Use this when querying segment variant content items.",
"schema": {
"type": "string"
},
"examples": {
"Default": {
"value": ""
},
"Segment One": {
"value": "segment-one"
}
}
},
{
"name": "Api-Key",
"in": "header",

View File

@@ -0,0 +1,232 @@
using Microsoft.AspNetCore.Http;
using Moq;
using NUnit.Framework;
using Umbraco.Cms.Api.Delivery.Services;
using Umbraco.Cms.Core.DeliveryApi;
using Umbraco.Cms.Core.Models.PublishedContent;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Routing;
using Umbraco.Cms.Tests.Common;
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Delivery.Services;
[TestFixture]
public class RequestRoutingServiceTests
{
[TestCase(null)]
[TestCase("")]
public void Empty_Path_Yields_Nothing(string? path)
{
var subject = new RequestRoutingService(
Mock.Of<IDomainCache>(),
Mock.Of<IHttpContextAccessor>(),
Mock.Of<IRequestStartItemProviderAccessor>(),
Mock.Of<IRequestCultureService>(),
Mock.Of<IVariationContextAccessor>());
var result = subject.GetContentRoute(path!);
Assert.IsEmpty(result);
}
[Test]
public void Explicit_Start_Item_Yields_Path_Prefixed_With_Start_Item_Id()
{
var startItem = new Mock<IPublishedContent>();
startItem.SetupGet(m => m.Id).Returns(1234);
var requestStartItemProviderMock = new Mock<IRequestStartItemProvider>();
requestStartItemProviderMock.Setup(m => m.GetStartItem()).Returns(startItem.Object);
var requestStartItemProvider = requestStartItemProviderMock.Object;
var requestStartItemProviderAccessorMock = new Mock<IRequestStartItemProviderAccessor>();
requestStartItemProviderAccessorMock.Setup(m => m.TryGetValue(out requestStartItemProvider)).Returns(true);
var subject = new RequestRoutingService(
Mock.Of<IDomainCache>(),
Mock.Of<IHttpContextAccessor>(),
requestStartItemProviderAccessorMock.Object,
Mock.Of<IRequestCultureService>(),
Mock.Of<IVariationContextAccessor>());
var result = subject.GetContentRoute("/some/where");
Assert.AreEqual("1234/some/where", result);
}
[TestCase("some/where")]
[TestCase("/some/where")]
public void Without_Domain_Binding_Yields_Path_Prefixed_With_Slash(string requestedPath)
{
var requestStartItemProviderMock = new Mock<IRequestStartItemProvider>();
var requestStartItemProvider = requestStartItemProviderMock.Object;
var requestStartItemProviderAccessorMock = new Mock<IRequestStartItemProviderAccessor>();
requestStartItemProviderAccessorMock.Setup(m => m.TryGetValue(out requestStartItemProvider)).Returns(true);
var httpContextAccessorMock = CreateHttpContextAccessorMock();
var subject = new RequestRoutingService(
Mock.Of<IDomainCache>(),
httpContextAccessorMock.Object,
requestStartItemProviderAccessorMock.Object,
Mock.Of<IRequestCultureService>(),
Mock.Of<IVariationContextAccessor>());
var result = subject.GetContentRoute(requestedPath);
Assert.AreEqual("/some/where", result);
}
[TestCase("some/where")]
[TestCase("/some/where")]
public void With_Domain_Binding_Yields_Path_Prefixed_With_Domain_Content_Id(string requestedPath)
{
var requestStartItemProviderMock = new Mock<IRequestStartItemProvider>();
var requestStartItemProvider = requestStartItemProviderMock.Object;
var requestStartItemProviderAccessorMock = new Mock<IRequestStartItemProviderAccessor>();
requestStartItemProviderAccessorMock.Setup(m => m.TryGetValue(out requestStartItemProvider)).Returns(true);
var httpContextAccessorMock = CreateHttpContextAccessorMock();
var domainCacheMock = GetDomainCacheMock(null);
var subject = new RequestRoutingService(
domainCacheMock.Object,
httpContextAccessorMock.Object,
requestStartItemProviderAccessorMock.Object,
Mock.Of<IRequestCultureService>(),
Mock.Of<IVariationContextAccessor>());
var result = subject.GetContentRoute(requestedPath);
Assert.AreEqual("1234/some/where", result);
}
[Test]
public void Domain_Binding_Culture_Sets_Variation_Context()
{
var requestStartItemProviderMock = new Mock<IRequestStartItemProvider>();
var requestStartItemProvider = requestStartItemProviderMock.Object;
var requestStartItemProviderAccessorMock = new Mock<IRequestStartItemProviderAccessor>();
requestStartItemProviderAccessorMock.Setup(m => m.TryGetValue(out requestStartItemProvider)).Returns(true);
var httpContextAccessorMock = CreateHttpContextAccessorMock();
var domainCacheMock = GetDomainCacheMock("da-DK");
var variationContextAccessor = new TestVariationContextAccessor();
var subject = new RequestRoutingService(
domainCacheMock.Object,
httpContextAccessorMock.Object,
requestStartItemProviderAccessorMock.Object,
Mock.Of<IRequestCultureService>(),
variationContextAccessor);
var result = subject.GetContentRoute("/some/where");
Assert.AreEqual("1234/some/where", result);
Assert.IsNotNull(variationContextAccessor.VariationContext);
Assert.AreEqual("da-DK", variationContextAccessor.VariationContext.Culture);
}
[Test]
public void Domain_Binding_Culture_Does_Not_Overwrite_Existing_Segment_Context()
{
var requestStartItemProviderMock = new Mock<IRequestStartItemProvider>();
var requestStartItemProvider = requestStartItemProviderMock.Object;
var requestStartItemProviderAccessorMock = new Mock<IRequestStartItemProviderAccessor>();
requestStartItemProviderAccessorMock.Setup(m => m.TryGetValue(out requestStartItemProvider)).Returns(true);
var httpContextAccessorMock = CreateHttpContextAccessorMock();
var domainCacheMock = GetDomainCacheMock("da-DK");
var variationContextAccessor = new TestVariationContextAccessor
{
VariationContext = new VariationContext("it-IT", "some-segment")
};
var subject = new RequestRoutingService(
domainCacheMock.Object,
httpContextAccessorMock.Object,
requestStartItemProviderAccessorMock.Object,
Mock.Of<IRequestCultureService>(),
variationContextAccessor);
var result = subject.GetContentRoute("/some/where");
Assert.AreEqual("1234/some/where", result);
Assert.IsNotNull(variationContextAccessor.VariationContext);
Assert.Multiple(() =>
{
Assert.AreEqual("da-DK", variationContextAccessor.VariationContext.Culture);
Assert.AreEqual("some-segment", variationContextAccessor.VariationContext.Segment);
});
}
[Test]
public void Explicit_Request_Culture_Overrides_Domain_Binding_Culture()
{
var requestStartItemProviderMock = new Mock<IRequestStartItemProvider>();
var requestStartItemProvider = requestStartItemProviderMock.Object;
var requestStartItemProviderAccessorMock = new Mock<IRequestStartItemProviderAccessor>();
requestStartItemProviderAccessorMock.Setup(m => m.TryGetValue(out requestStartItemProvider)).Returns(true);
var httpContextAccessorMock = CreateHttpContextAccessorMock();
var domainCacheMock = GetDomainCacheMock("da-DK");
const string expectedCulture = "it-IT";
const string expectedSegment = "some-segment";
var variationContextAccessor = new TestVariationContextAccessor
{
VariationContext = new VariationContext(expectedCulture, expectedSegment)
};
var requestCultureServiceMock = new Mock<IRequestCultureService>();
requestCultureServiceMock
.Setup(m => m.GetRequestedCulture())
.Returns(expectedCulture);
var subject = new RequestRoutingService(
domainCacheMock.Object,
httpContextAccessorMock.Object,
requestStartItemProviderAccessorMock.Object,
requestCultureServiceMock.Object,
variationContextAccessor);
var result = subject.GetContentRoute("/some/where");
Assert.AreEqual("1234/some/where", result);
Assert.IsNotNull(variationContextAccessor.VariationContext);
Assert.Multiple(() =>
{
Assert.AreEqual(expectedCulture, variationContextAccessor.VariationContext.Culture);
Assert.AreEqual(expectedSegment, variationContextAccessor.VariationContext.Segment);
});
}
private static Mock<IHttpContextAccessor> CreateHttpContextAccessorMock()
{
var httpContextAccessorMock = new Mock<IHttpContextAccessor>();
httpContextAccessorMock
.SetupGet(m => m.HttpContext)
.Returns(
new DefaultHttpContext
{
Request =
{
Scheme = "https",
Host = new HostString("some.host"),
Path = "/",
QueryString = new QueryString(string.Empty)
}
});
return httpContextAccessorMock;
}
private static Mock<IDomainCache> GetDomainCacheMock(string? culture)
{
var domainCacheMock = new Mock<IDomainCache>();
domainCacheMock
.Setup(m => m.GetAll(It.IsAny<bool>()))
.Returns([
new Domain(1, "some.host", 1234, culture, false, 1)
]);
return domainCacheMock;
}
}