diff --git a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ContentApiControllerBase.cs b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ContentApiControllerBase.cs index 1db4d9d395..1d6919d2a4 100644 --- a/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ContentApiControllerBase.cs +++ b/src/Umbraco.Cms.Api.Delivery/Controllers/Content/ContentApiControllerBase.cs @@ -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)] diff --git a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs index cca2c8e09b..ca64e175a0 100644 --- a/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Cms.Api.Delivery/DependencyInjection/UmbracoBuilderExtensions.cs @@ -49,6 +49,7 @@ public static class UmbracoBuilderExtensions : provider.GetRequiredService(); }); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/ContextualizeFromAcceptHeadersAttribute.cs b/src/Umbraco.Cms.Api.Delivery/Filters/ContextualizeFromAcceptHeadersAttribute.cs new file mode 100644 index 0000000000..aaa7dc8d4f --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Filters/ContextualizeFromAcceptHeadersAttribute.cs @@ -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) + { + } + } +} diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/LocalizeFromAcceptLanguageHeaderAttribute.cs b/src/Umbraco.Cms.Api.Delivery/Filters/LocalizeFromAcceptLanguageHeaderAttribute.cs deleted file mode 100644 index 7bd2fec9bd..0000000000 --- a/src/Umbraco.Cms.Api.Delivery/Filters/LocalizeFromAcceptLanguageHeaderAttribute.cs +++ /dev/null @@ -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) - { - } - } -} diff --git a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs index 0d2d35fb23..39d7125e55 100644 --- a/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs +++ b/src/Umbraco.Cms.Api.Delivery/Filters/SwaggerContentDocumentationFilter.cs @@ -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 + { + { "Default", new OpenApiExample { Value = new OpenApiString(string.Empty) } }, + { "Segment One", new OpenApiExample { Value = new OpenApiString("segment-one") } } + } + }); + AddApiKey(operation); operation.Parameters.Add(new OpenApiParameter diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestCultureService.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestCultureService.cs index 4c2d877701..696b8b3953 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/RequestCultureService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestCultureService.cs @@ -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) + { + } /// public string? GetRequestedCulture() @@ -21,15 +19,10 @@ internal sealed partial class RequestCultureService : RequestHeaderHandler, IReq return ValidLanguageHeaderRegex().IsMatch(acceptLanguage) ? acceptLanguage : null; } - /// + [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", diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestRoutingService.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestRoutingService.cs index 67cf9c9fc0..8e558d0702 100644 --- a/src/Umbraco.Cms.Api.Delivery/Services/RequestRoutingService.cs +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestRoutingService.cs @@ -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; + } /// 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 diff --git a/src/Umbraco.Cms.Api.Delivery/Services/RequestSegmentService.cs b/src/Umbraco.Cms.Api.Delivery/Services/RequestSegmentService.cs new file mode 100644 index 0000000000..46a689c539 --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Services/RequestSegmentService.cs @@ -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) + { + } + + /// + public string? GetRequestedSegment() + => GetHeaderValue("Accept-Segment"); +} diff --git a/src/Umbraco.Core/DeliveryApi/IRequestCultureService.cs b/src/Umbraco.Core/DeliveryApi/IRequestCultureService.cs index 2ca725ae8b..431ad7e5b1 100644 --- a/src/Umbraco.Core/DeliveryApi/IRequestCultureService.cs +++ b/src/Umbraco.Core/DeliveryApi/IRequestCultureService.cs @@ -7,9 +7,6 @@ public interface IRequestCultureService /// string? GetRequestedCulture(); - /// - /// Updates the current request culture if applicable. - /// - /// The culture to use for the current request. + [Obsolete("Use IVariationContextAccessor to manipulate the variation context. Scheduled for removal in V17.")] void SetRequestCulture(string culture); } diff --git a/src/Umbraco.Core/DeliveryApi/IRequestSegmentService.cs b/src/Umbraco.Core/DeliveryApi/IRequestSegmentService.cs new file mode 100644 index 0000000000..c7c3c3425f --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IRequestSegmentService.cs @@ -0,0 +1,9 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +public interface IRequestSegmmentService +{ + /// + /// Gets the requested segment from the "Accept-Segment" header, if present. + /// + string? GetRequestedSegment(); +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/OpenApiContractTest.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/OpenApiContractTest.cs index 5626cbea20..2e95b473f6 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/OpenApiContractTest.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/DeliveryApi/OpenApiContractTest.cs @@ -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", diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Delivery/Services/RequestRoutingServiceTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Delivery/Services/RequestRoutingServiceTests.cs new file mode 100644 index 0000000000..91546ccc73 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Delivery/Services/RequestRoutingServiceTests.cs @@ -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(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); + + 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(); + startItem.SetupGet(m => m.Id).Returns(1234); + + var requestStartItemProviderMock = new Mock(); + requestStartItemProviderMock.Setup(m => m.GetStartItem()).Returns(startItem.Object); + + var requestStartItemProvider = requestStartItemProviderMock.Object; + var requestStartItemProviderAccessorMock = new Mock(); + requestStartItemProviderAccessorMock.Setup(m => m.TryGetValue(out requestStartItemProvider)).Returns(true); + + var subject = new RequestRoutingService( + Mock.Of(), + Mock.Of(), + requestStartItemProviderAccessorMock.Object, + Mock.Of(), + Mock.Of()); + + 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(); + var requestStartItemProvider = requestStartItemProviderMock.Object; + var requestStartItemProviderAccessorMock = new Mock(); + requestStartItemProviderAccessorMock.Setup(m => m.TryGetValue(out requestStartItemProvider)).Returns(true); + + var httpContextAccessorMock = CreateHttpContextAccessorMock(); + + var subject = new RequestRoutingService( + Mock.Of(), + httpContextAccessorMock.Object, + requestStartItemProviderAccessorMock.Object, + Mock.Of(), + Mock.Of()); + + 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(); + var requestStartItemProvider = requestStartItemProviderMock.Object; + var requestStartItemProviderAccessorMock = new Mock(); + 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(), + Mock.Of()); + + var result = subject.GetContentRoute(requestedPath); + Assert.AreEqual("1234/some/where", result); + } + + [Test] + public void Domain_Binding_Culture_Sets_Variation_Context() + { + var requestStartItemProviderMock = new Mock(); + var requestStartItemProvider = requestStartItemProviderMock.Object; + var requestStartItemProviderAccessorMock = new Mock(); + 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(), + 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(); + var requestStartItemProvider = requestStartItemProviderMock.Object; + var requestStartItemProviderAccessorMock = new Mock(); + 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(), + 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(); + var requestStartItemProvider = requestStartItemProviderMock.Object; + var requestStartItemProviderAccessorMock = new Mock(); + 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(); + 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 CreateHttpContextAccessorMock() + { + var httpContextAccessorMock = new Mock(); + 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 GetDomainCacheMock(string? culture) + { + var domainCacheMock = new Mock(); + domainCacheMock + .Setup(m => m.GetAll(It.IsAny())) + .Returns([ + new Domain(1, "some.host", 1234, culture, false, 1) + ]); + return domainCacheMock; + } +}