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
+
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/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';
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}>
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;
+ }
+}