Skip to content

Commit 07203b7

Browse files
authored
Segments: Property level default segment fallback (#20309)
Property level default segment fallback
1 parent cdb2be6 commit 07203b7

File tree

4 files changed

+175
-87
lines changed

4 files changed

+175
-87
lines changed

src/Umbraco.Core/DeliveryApi/ApiContentBuilderBase.cs

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,22 +36,6 @@ protected ApiContentBuilderBase(
3636
return default;
3737
}
3838

39-
// If a segment is requested and no segmented properties have any values, we consider the segment as not created or non-existing and return null.
40-
// This aligns the behaviour of the API when it comes to "Accept-Segment" and "Accept-Language" requests, so 404 is returned for both when
41-
// the segment or language is not created or does not exist.
42-
// It also aligns with what we show in the backoffice for whether a segment is "Published" or "Not created".
43-
// Requested languages that aren't created or don't exist will already have exited early in the route builder.
44-
var segment = VariationContextAccessor.VariationContext?.Segment;
45-
if (segment.IsNullOrWhiteSpace() is false
46-
&& content.ContentType.VariesBySegment()
47-
&& content
48-
.Properties
49-
.Where(p => p.PropertyType.VariesBySegment())
50-
.All(p => p.HasValue(VariationContextAccessor.VariationContext?.Culture, segment) is false))
51-
{
52-
return default;
53-
}
54-
5539
IDictionary<string, object?> properties =
5640
_outputExpansionStrategyAccessor.TryGetValue(out IOutputExpansionStrategy? outputExpansionStrategy)
5741
? outputExpansionStrategy.MapContentProperties(content)

src/Umbraco.Core/Models/PublishedContent/PublishedValueFallback.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ public bool TryGetValue<T>(IPublishedProperty property, string? culture, string?
3232
{
3333
_variationContextAccessor.ContextualizeVariation(property.PropertyType.Variations, property.Alias, ref culture, ref segment);
3434

35+
if (TryGetValueForDefaultSegment(property, culture, segment, out value))
36+
{
37+
return true;
38+
}
39+
3540
foreach (var f in fallback)
3641
{
3742
switch (f)
@@ -80,6 +85,11 @@ public bool TryGetValue<T>(IPublishedElement content, string alias, string? cult
8085

8186
_variationContextAccessor.ContextualizeVariation(propertyType.Variations, alias, ref culture, ref segment);
8287

88+
if (TryGetValueForDefaultSegment(content, alias, culture, segment, out value))
89+
{
90+
return true;
91+
}
92+
8393
foreach (var f in fallback)
8494
{
8595
switch (f)
@@ -128,6 +138,11 @@ public virtual bool TryGetValue<T>(IPublishedContent content, string alias, stri
128138
noValueProperty = content.GetProperty(alias);
129139
}
130140

141+
if (propertyType != null && TryGetValueForDefaultSegment(content, alias, culture, segment, out value))
142+
{
143+
return true;
144+
}
145+
131146
// note: we don't support "recurse & language" which would walk up the tree,
132147
// looking at languages at each level - should someone need it... they'll have
133148
// to implement it.
@@ -179,6 +194,30 @@ private NotSupportedException NotSupportedFallbackMethod(int fallback, string le
179194
new NotSupportedException(
180195
$"Fallback {GetType().Name} does not support fallback code '{fallback}' at {level} level.");
181196

197+
private bool TryGetValueForDefaultSegment<T>(IPublishedElement content, string alias, string? culture, string? segment, out T? value)
198+
{
199+
IPublishedProperty? property = content.GetProperty(alias);
200+
if (property is not null)
201+
{
202+
return TryGetValueForDefaultSegment(property, culture, segment, out value);
203+
}
204+
205+
value = default;
206+
return false;
207+
}
208+
209+
private bool TryGetValueForDefaultSegment<T>(IPublishedProperty property, string? culture, string? segment, out T? value)
210+
{
211+
if (segment.IsNullOrWhiteSpace() is false && property.HasValue(culture, segment: string.Empty))
212+
{
213+
value = property.Value<T>(this, culture, segment: string.Empty);
214+
return true;
215+
}
216+
217+
value = default;
218+
return false;
219+
}
220+
182221
// tries to get a value, recursing the tree
183222
// because we recurse, content may not even have the a property with the specified alias (but only some ancestor)
184223
// in case no value was found, noValueProperty contains the first property that was found (which does not have a value)
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
using Microsoft.AspNetCore.Http;
2+
using NUnit.Framework;
3+
using Umbraco.Cms.Core;
4+
using Umbraco.Cms.Core.Cache;
5+
using Umbraco.Cms.Core.DeliveryApi;
6+
using Umbraco.Cms.Core.Models;
7+
using Umbraco.Cms.Core.Models.PublishedContent;
8+
using Umbraco.Cms.Core.Services;
9+
using Umbraco.Cms.Core.Services.Changes;
10+
using Umbraco.Cms.Core.Web;
11+
using Umbraco.Cms.Tests.Common.Builders;
12+
using Umbraco.Cms.Tests.Common.Builders.Extensions;
13+
using Umbraco.Cms.Tests.Common.Testing;
14+
using Umbraco.Cms.Tests.Integration.Testing;
15+
16+
namespace Umbraco.Cms.Tests.Integration.Umbraco.Core.PublishedContent;
17+
18+
[TestFixture]
19+
[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)]
20+
public class PublishedContentFallbackTests : UmbracoIntegrationTest
21+
{
22+
private IContentService ContentService => GetRequiredService<IContentService>();
23+
24+
private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>();
25+
26+
private IUmbracoContextAccessor UmbracoContextAccessor => GetRequiredService<IUmbracoContextAccessor>();
27+
28+
private IUmbracoContextFactory UmbracoContextFactory => GetRequiredService<IUmbracoContextFactory>();
29+
30+
private IVariationContextAccessor VariationContextAccessor => GetRequiredService<IVariationContextAccessor>();
31+
32+
private IPublishedValueFallback PublishedValueFallback => GetRequiredService<IPublishedValueFallback>();
33+
34+
private ContentCacheRefresher ContentCacheRefresher => GetRequiredService<ContentCacheRefresher>();
35+
36+
private IApiContentBuilder ApiContentBuilder => GetRequiredService<IApiContentBuilder>();
37+
38+
protected override void CustomTestSetup(IUmbracoBuilder builder)
39+
=> builder
40+
.AddUmbracoHybridCache()
41+
.AddDeliveryApi();
42+
43+
[SetUp]
44+
public void SetUpTest()
45+
{
46+
var httpContextAccessor = GetRequiredService<IHttpContextAccessor>();
47+
48+
httpContextAccessor.HttpContext = new DefaultHttpContext
49+
{
50+
Request =
51+
{
52+
Scheme = "https",
53+
Host = new HostString("localhost"),
54+
Path = "/",
55+
QueryString = new QueryString(string.Empty)
56+
},
57+
RequestServices = Services
58+
};
59+
}
60+
61+
[TestCase("Invariant title", "Segmented title", "Segmented title")]
62+
[TestCase(null, "Segmented title", "Segmented title")]
63+
[TestCase("Invariant title", null, "Invariant title")]
64+
[TestCase(null, null, null)]
65+
public async Task Property_Value_Performs_Fallback_To_Default_Segment_For_Templated_Rendering(string? invariantTitle, string? segmentedTitle, string? expectedResult)
66+
{
67+
var publishedContent = await SetupSegmentedContentAsync(invariantTitle, segmentedTitle);
68+
69+
// NOTE: the TextStringValueConverter.ConvertIntermediateToObject() explicitly converts a null source value to an empty string
70+
71+
var segmentedResult = publishedContent.Value<string>(PublishedValueFallback, "title", segment: "s1");
72+
Assert.AreEqual(expectedResult ?? string.Empty, segmentedResult);
73+
74+
var invariantResult = publishedContent.Value<string>(PublishedValueFallback, "title", segment: string.Empty);
75+
Assert.AreEqual(invariantTitle ?? string.Empty, invariantResult);
76+
}
77+
78+
[TestCase("Invariant title", "Segmented title", "Segmented title")]
79+
[TestCase(null, "Segmented title", "Segmented title")]
80+
[TestCase("Invariant title", null, "Invariant title")]
81+
[TestCase(null, null, null)]
82+
public async Task Property_Value_Performs_Fallback_To_Default_Segment_For_Delivery_Api_Output(string? invariantTitle, string? segmentedTitle, string? expectedResult)
83+
{
84+
UmbracoContextFactory.EnsureUmbracoContext();
85+
86+
var publishedContent = await SetupSegmentedContentAsync(invariantTitle, segmentedTitle);
87+
88+
VariationContextAccessor.VariationContext = new VariationContext(culture: null, segment: "s1");
89+
var apiContent = ApiContentBuilder.Build(publishedContent);
90+
Assert.IsNotNull(apiContent);
91+
Assert.IsTrue(apiContent.Properties.TryGetValue("title", out var segmentedValue));
92+
Assert.AreEqual(expectedResult, segmentedValue);
93+
94+
VariationContextAccessor.VariationContext = new VariationContext(culture: null, segment: null);
95+
apiContent = ApiContentBuilder.Build(publishedContent);
96+
Assert.IsNotNull(apiContent);
97+
Assert.IsTrue(apiContent.Properties.TryGetValue("title", out var invariantValue));
98+
Assert.AreEqual(invariantTitle, invariantValue);
99+
}
100+
101+
private async Task<IPublishedContent> SetupSegmentedContentAsync(string? invariantTitle, string? segmentedTitle)
102+
{
103+
var contentType = new ContentTypeBuilder()
104+
.WithAlias("theContentType")
105+
.WithContentVariation(ContentVariation.Segment)
106+
.AddPropertyType()
107+
.WithAlias("title")
108+
.WithName("Title")
109+
.WithDataTypeId(Constants.DataTypes.Textbox)
110+
.WithPropertyEditorAlias(Constants.PropertyEditors.Aliases.TextBox)
111+
.WithValueStorageType(ValueStorageType.Nvarchar)
112+
.WithVariations(ContentVariation.Segment)
113+
.Done()
114+
.WithAllowAsRoot(true)
115+
.Build();
116+
await ContentTypeService.CreateAsync(contentType, Constants.Security.SuperUserKey);
117+
118+
var content = new ContentBuilder()
119+
.WithContentType(contentType)
120+
.WithName("Content")
121+
.Build();
122+
content.SetValue("title", invariantTitle);
123+
content.SetValue("title", segmentedTitle, segment: "s1");
124+
ContentService.Save(content);
125+
ContentService.Publish(content, ["*"]);
126+
127+
ContentCacheRefresher.Refresh([new ContentCacheRefresher.JsonPayload { ChangeTypes = TreeChangeTypes.RefreshAll }]);
128+
129+
UmbracoContextAccessor.Clear();
130+
var umbracoContext = UmbracoContextFactory.EnsureUmbracoContext().UmbracoContext;
131+
var publishedContent = umbracoContext.Content.GetById(content.Key);
132+
Assert.IsNotNull(publishedContent);
133+
134+
return publishedContent;
135+
}
136+
}

tests/Umbraco.Tests.UnitTests/Umbraco.Core/DeliveryApi/ContentBuilderTests.cs

Lines changed: 0 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -144,75 +144,4 @@ public void ContentBuilder_ReturnsNullForUnRoutableContent()
144144

145145
Assert.Null(result);
146146
}
147-
148-
[TestCase("Shared value", "Segmented value", false)]
149-
[TestCase(null, "Segmented value", false)]
150-
[TestCase("Shared value", null, true)]
151-
[TestCase(null, null, true)]
152-
public void ContentBuilder_ReturnsNullForRequestedSegmentThatIsNotCreated(object? sharedValue, object? segmentedValue, bool expectNull)
153-
{
154-
var content = new Mock<IPublishedContent>();
155-
156-
var sharedPropertyValueConverter = new Mock<IDeliveryApiPropertyValueConverter>();
157-
sharedPropertyValueConverter.Setup(p => p.IsValue(It.IsAny<object?>(), It.IsAny<PropertyValueLevel>())).Returns(sharedValue is not null);
158-
sharedPropertyValueConverter.Setup(p => p.ConvertIntermediateToDeliveryApiObject(
159-
It.IsAny<IPublishedElement>(),
160-
It.IsAny<IPublishedPropertyType>(),
161-
It.IsAny<PropertyCacheLevel>(),
162-
It.IsAny<object?>(),
163-
It.IsAny<bool>(),
164-
It.IsAny<bool>())).Returns(sharedValue);
165-
sharedPropertyValueConverter.Setup(p => p.IsConverter(It.IsAny<IPublishedPropertyType>())).Returns(true);
166-
sharedPropertyValueConverter.Setup(p => p.GetPropertyCacheLevel(It.IsAny<IPublishedPropertyType>())).Returns(PropertyCacheLevel.None);
167-
sharedPropertyValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevel(It.IsAny<IPublishedPropertyType>())).Returns(PropertyCacheLevel.None);
168-
169-
var sharedPropertyType = SetupPublishedPropertyType(sharedPropertyValueConverter.Object, "sharedMessage", "Umbraco.Textstring");
170-
var sharedProperty = new PublishedElementPropertyBase(sharedPropertyType, content.Object, false, PropertyCacheLevel.None, new VariationContext(), Mock.Of<ICacheManager>());
171-
172-
var segmentedPropertyValueConverter = new Mock<IDeliveryApiPropertyValueConverter>();
173-
segmentedPropertyValueConverter.Setup(p => p.IsValue(It.IsAny<object?>(), It.IsAny<PropertyValueLevel>())).Returns(segmentedValue is not null);
174-
segmentedPropertyValueConverter.Setup(p => p.ConvertIntermediateToDeliveryApiObject(
175-
It.IsAny<IPublishedElement>(),
176-
It.IsAny<IPublishedPropertyType>(),
177-
It.IsAny<PropertyCacheLevel>(),
178-
It.IsAny<object?>(),
179-
It.IsAny<bool>(),
180-
It.IsAny<bool>())).Returns(segmentedValue);
181-
segmentedPropertyValueConverter.Setup(p => p.IsConverter(It.IsAny<IPublishedPropertyType>())).Returns(true);
182-
segmentedPropertyValueConverter.Setup(p => p.GetPropertyCacheLevel(It.IsAny<IPublishedPropertyType>())).Returns(PropertyCacheLevel.None);
183-
segmentedPropertyValueConverter.Setup(p => p.GetDeliveryApiPropertyCacheLevel(It.IsAny<IPublishedPropertyType>())).Returns(PropertyCacheLevel.None);
184-
185-
var segmentedPropertyType = SetupPublishedPropertyType(segmentedPropertyValueConverter.Object, "segmentedMessage", "Umbraco.Textstring", contentVariation: ContentVariation.Segment);
186-
var segmentedProperty = new PublishedElementPropertyBase(segmentedPropertyType, content.Object, false, PropertyCacheLevel.None, new VariationContext(), Mock.Of<ICacheManager>());
187-
188-
var contentType = new Mock<IPublishedContentType>();
189-
contentType.SetupGet(c => c.Alias).Returns("thePageType");
190-
contentType.SetupGet(c => c.ItemType).Returns(PublishedItemType.Content);
191-
contentType.SetupGet(c => c.Variations).Returns(ContentVariation.Segment);
192-
193-
var key = Guid.NewGuid();
194-
var urlSegment = "url-segment";
195-
var name = "The page";
196-
ConfigurePublishedContentMock(content, key, name, urlSegment, contentType.Object, [sharedProperty, segmentedProperty]);
197-
198-
var routeBuilderMock = new Mock<IApiContentRouteBuilder>();
199-
routeBuilderMock
200-
.Setup(r => r.Build(content.Object, It.IsAny<string?>()))
201-
.Returns(new ApiContentRoute(content.Object.UrlSegment!, new ApiContentStartItem(Guid.NewGuid(), "/")));
202-
203-
var variationContextAccessorMock = new Mock<IVariationContextAccessor>();
204-
variationContextAccessorMock.Setup(v => v.VariationContext).Returns(new VariationContext(segment: "missingSegment"));
205-
206-
var builder = new ApiContentBuilder(new ApiContentNameProvider(), routeBuilderMock.Object, CreateOutputExpansionStrategyAccessor(), variationContextAccessorMock.Object);
207-
var result = builder.Build(content.Object);
208-
209-
if (expectNull)
210-
{
211-
Assert.IsNull(result);
212-
}
213-
else
214-
{
215-
Assert.IsNotNull(result);
216-
}
217-
}
218147
}

0 commit comments

Comments
 (0)