From 64f1e174792e384ad1c405f6eb2008a027f9a08e Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Wed, 23 Apr 2025 07:45:04 +0200 Subject: [PATCH 1/4] Segment support for the Delivery API (#19082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Segment support for the Delivery API * Do not apply empty strings as variance. --------- Co-authored-by: Niels Lyngsø --- .../Content/ContentApiControllerBase.cs | 2 +- .../UmbracoBuilderExtensions.cs | 1 + ...ContextualizeFromAcceptHeadersAttribute.cs | 53 ++++ ...calizeFromAcceptLanguageHeaderAttribute.cs | 37 --- .../SwaggerContentDocumentationFilter.cs | 14 ++ .../Services/RequestCultureService.cs | 19 +- .../Services/RequestRoutingService.cs | 19 +- .../Services/RequestSegmentService.cs | 16 ++ .../DeliveryApi/IRequestCultureService.cs | 5 +- .../DeliveryApi/IRequestSegmentService.cs | 9 + .../DeliveryApi/OpenApiContractTest.cs | 64 +++++ .../Services/RequestRoutingServiceTests.cs | 232 ++++++++++++++++++ 12 files changed, 411 insertions(+), 60 deletions(-) create mode 100644 src/Umbraco.Cms.Api.Delivery/Filters/ContextualizeFromAcceptHeadersAttribute.cs delete mode 100644 src/Umbraco.Cms.Api.Delivery/Filters/LocalizeFromAcceptLanguageHeaderAttribute.cs create mode 100644 src/Umbraco.Cms.Api.Delivery/Services/RequestSegmentService.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IRequestSegmentService.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Delivery/Services/RequestRoutingServiceTests.cs 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; + } +} From fcddba5c73655e09cae7ca213bc6def09fb2dbe5 Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Wed, 23 Apr 2025 07:59:51 +0200 Subject: [PATCH 2/4] Update version of MiniProfiler.AspNetCore.Mvc to align with MiniProfiler.Shared. (#19107) Co-authored-by: Kenn Jacobsen --- Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0817e00c6a..b5f52490e5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -53,7 +53,7 @@ - + @@ -100,4 +100,4 @@ - \ No newline at end of file + From 8849647dcda5905dd1d9fc08af974cf3d4468374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Wed, 23 Apr 2025 09:08:08 +0200 Subject: [PATCH 3/4] declare type and constant (#19091) --- .../entity-content-type-condition/index.ts | 4 ++- .../content-type/conditions/constants.ts | 4 +++ .../content/content-type/conditions/types.ts | 32 ++++++++++--------- .../content/content-type/constants.ts | 1 + .../packages/content/content-type/types.ts | 1 + 5 files changed, 26 insertions(+), 16 deletions(-) create mode 100644 src/Umbraco.Web.UI.Client/src/packages/content/content-type/conditions/constants.ts diff --git a/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/index.ts b/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/index.ts index ac4a90f48a..43cc3a71a3 100644 --- a/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/index.ts +++ b/src/Umbraco.Web.UI.Client/examples/entity-content-type-condition/index.ts @@ -1,3 +1,5 @@ +import { UMB_WORKSPACE_CONTENT_TYPE_ALIAS_CONDITION_ALIAS } from '@umbraco-cms/backoffice/content-type'; + const workspace: UmbExtensionManifest = { type: 'workspaceView', alias: 'Example.WorkspaceView.EntityContentTypeCondition', @@ -10,7 +12,7 @@ const workspace: UmbExtensionManifest = { }, conditions: [ { - alias: 'Umb.Condition.WorkspaceContentTypeAlias', + alias: UMB_WORKSPACE_CONTENT_TYPE_ALIAS_CONDITION_ALIAS, //match : 'blogPost' oneOf: ['blogPost', 'mediaType1'], }, diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/conditions/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/conditions/constants.ts new file mode 100644 index 0000000000..0bf665f771 --- /dev/null +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/conditions/constants.ts @@ -0,0 +1,4 @@ +/** + * Workspace Content Type Alias condition alias + */ +export const UMB_WORKSPACE_CONTENT_TYPE_ALIAS_CONDITION_ALIAS = 'Umb.Condition.WorkspaceContentTypeAlias'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/conditions/types.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/conditions/types.ts index 9ad295ee57..e892e9cc31 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/conditions/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/conditions/types.ts @@ -1,20 +1,22 @@ +import type { UMB_WORKSPACE_CONTENT_TYPE_ALIAS_CONDITION_ALIAS } from './constants.js'; import type { UmbConditionConfigBase } from '@umbraco-cms/backoffice/extension-api'; -export type UmbWorkspaceContentTypeAliasConditionConfig = - UmbConditionConfigBase<'Umb.Condition.WorkspaceContentTypeAlias'> & { - /** - * Define a content type alias in which workspace this extension should be available - * @example - * Depends on implementation, but i.e. "article", "image", "blockPage" - */ - match?: string; - /** - * Define one or more content type aliases in which workspace this extension should be available - * @example - * ["article", "image", "blockPage"] - */ - oneOf?: Array; - }; +export type UmbWorkspaceContentTypeAliasConditionConfig = UmbConditionConfigBase< + typeof UMB_WORKSPACE_CONTENT_TYPE_ALIAS_CONDITION_ALIAS +> & { + /** + * Define a content type alias in which workspace this extension should be available + * @example + * Depends on implementation, but i.e. "article", "image", "blockPage" + */ + match?: string; + /** + * Define one or more content type aliases in which workspace this extension should be available + * @example + * ["article", "image", "blockPage"] + */ + oneOf?: Array; +}; /** * @deprecated Use `UmbWorkspaceContentTypeAliasConditionConfig` instead. This will be removed in Umbraco 17. */ diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/constants.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/constants.ts index a9382bfee1..3d9d65e4f0 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/constants.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/constants.ts @@ -1,2 +1,3 @@ export * from './modals/constants.js'; export * from './workspace/constants.js'; +export * from './conditions/constants.js'; diff --git a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/types.ts b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/types.ts index 8622e3fd9d..4364ef2e5b 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/content/content-type/types.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/content/content-type/types.ts @@ -2,6 +2,7 @@ import type { CompositionTypeModel } from '@umbraco-cms/backoffice/external/back import type { UmbReferenceByUnique } from '@umbraco-cms/backoffice/models'; export type * from './composition/types.js'; +export type * from './conditions/types.js'; export type UmbPropertyContainerTypes = 'Group' | 'Tab'; From 18a8e3bde5442dc7c81214e80fc1859f320af87b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lyngs=C3=B8?= Date: Wed, 23 Apr 2025 09:12:56 +0200 Subject: [PATCH 4/4] implement readonly mode for umb-property-editor-ui-document-type-picker (#19089) --- .../input-document-type.element.ts | 17 ++++++++++++++--- .../document-type-picker/manifests.ts | 1 + ...ty-editor-ui-document-type-picker.element.ts | 4 ++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/components/input-document-type/input-document-type.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/components/input-document-type/input-document-type.element.ts index 0c38be95d5..c32258db97 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/components/input-document-type/input-document-type.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/components/input-document-type/input-document-type.element.ts @@ -2,7 +2,7 @@ import type { UmbDocumentTypeItemModel, UmbDocumentTypeTreeItemModel } from '../ import { UMB_DOCUMENT_TYPE_WORKSPACE_MODAL } from '../../constants.js'; import { UMB_EDIT_DOCUMENT_TYPE_WORKSPACE_PATH_PATTERN } from '../../paths.js'; import { UmbDocumentTypePickerInputContext } from './input-document-type.context.js'; -import { css, html, customElement, property, state, repeat, nothing } from '@umbraco-cms/backoffice/external/lit'; +import { css, html, customElement, property, state, repeat, nothing, when } from '@umbraco-cms/backoffice/external/lit'; import { splitStringToArray } from '@umbraco-cms/backoffice/utils'; import { UmbChangeEvent } from '@umbraco-cms/backoffice/event'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; @@ -105,6 +105,10 @@ export class UmbInputDocumentTypeElement extends UmbFormControlMixin 0 ? this.selection.join(',') : undefined; } + + @property({ type: Boolean, attribute: 'readonly' }) + readonly?: boolean; + @state() private _items?: Array; @@ -176,7 +180,7 @@ export class UmbInputDocumentTypeElement extends UmbFormControlMixin 0 && this.selection.length >= this.max) return nothing; + if (this.readonly || (this.max > 0 && this.selection.length >= this.max)) return nothing; return html` ${this.#renderIcon(item)} - this.#removeItem(item)} label=${this.localize.term('general_remove')}> + ${when( + !this.readonly, + () => html` + this.#removeItem(item)}> + `, + )} `; diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-editors/document-type-picker/manifests.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-editors/document-type-picker/manifests.ts index 21d09d0cfe..4f969cc8ca 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-editors/document-type-picker/manifests.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-editors/document-type-picker/manifests.ts @@ -9,6 +9,7 @@ export const manifest: ManifestPropertyEditorUi = { label: 'Document Type Picker', icon: 'icon-document-dashed-line', group: 'advanced', + supportsReadOnly: true, settings: { properties: [ { diff --git a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-editors/document-type-picker/property-editor-ui-document-type-picker.element.ts b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-editors/document-type-picker/property-editor-ui-document-type-picker.element.ts index 52a044deec..b9ce0b8d16 100644 --- a/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-editors/document-type-picker/property-editor-ui-document-type-picker.element.ts +++ b/src/Umbraco.Web.UI.Client/src/packages/documents/document-types/property-editors/document-type-picker/property-editor-ui-document-type-picker.element.ts @@ -23,6 +23,9 @@ export class UmbPropertyEditorUIDocumentTypePickerElement extends UmbLitElement this.onlyElementTypes = config.getValueByAlias('onlyPickElementTypes') ?? false; } + @property({ type: Boolean, attribute: 'readonly' }) + readonly = false; + @state() min = 0; @@ -43,6 +46,7 @@ export class UmbPropertyEditorUIDocumentTypePickerElement extends UmbLitElement .min=${this.min} .max=${this.max} .value=${this.value} + .readonly=${this.readonly} .elementTypesOnly=${this.onlyElementTypes ?? false} @change=${this.#onChange}>