Skip to content

Commit 32d0cb4

Browse files
authored
V14: Adding the ability to conditionally serialize version bound properties for the Delivery API (#16731)
* Property level versioning for the Delivery API using a custom System.Text.Json resolver * Adding a converter base class that custom converters can implement * Revert resolver * Use IHttpContextAccessor for the API version * Fix attribute and checks in ShouldIncludeProperty * Fix enumeration * Fix comment * Unit tests * Refactoring * Remove Assert.Multiple where no needed
1 parent d451390 commit 32d0cb4

File tree

3 files changed

+321
-0
lines changed

3 files changed

+321
-0
lines changed
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using System.Reflection;
2+
using System.Text.Json;
3+
using System.Text.Json.Serialization;
4+
using Asp.Versioning;
5+
using Microsoft.AspNetCore.Http;
6+
using Umbraco.Cms.Core.DeliveryApi;
7+
8+
namespace Umbraco.Cms.Api.Delivery.Json;
9+
10+
public abstract class DeliveryApiVersionAwareJsonConverterBase<T> : JsonConverter<T>
11+
{
12+
private readonly IHttpContextAccessor _httpContextAccessor;
13+
private readonly JsonConverter<T> _defaultConverter = (JsonConverter<T>)JsonSerializerOptions.Default.GetConverter(typeof(T));
14+
15+
public DeliveryApiVersionAwareJsonConverterBase(IHttpContextAccessor httpContextAccessor)
16+
=> _httpContextAccessor = httpContextAccessor;
17+
18+
/// <inheritdoc />
19+
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
20+
=> _defaultConverter.Read(ref reader, typeToConvert, options);
21+
22+
/// <inheritdoc />
23+
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
24+
{
25+
Type type = typeof(T);
26+
var apiVersion = GetApiVersion();
27+
28+
// Get the properties in the specified order
29+
PropertyInfo[] properties = type.GetProperties().OrderBy(GetPropertyOrder).ToArray();
30+
31+
writer.WriteStartObject();
32+
33+
foreach (PropertyInfo property in properties)
34+
{
35+
// Filter out properties based on the API version
36+
var include = apiVersion is null || ShouldIncludeProperty(property, apiVersion.Value);
37+
38+
if (include is false)
39+
{
40+
continue;
41+
}
42+
43+
var propertyName = property.Name;
44+
writer.WritePropertyName(options.PropertyNamingPolicy?.ConvertName(propertyName) ?? propertyName);
45+
JsonSerializer.Serialize(writer, property.GetValue(value), options);
46+
}
47+
48+
writer.WriteEndObject();
49+
}
50+
51+
private int? GetApiVersion()
52+
{
53+
HttpContext? httpContext = _httpContextAccessor.HttpContext;
54+
ApiVersion? apiVersion = httpContext?.GetRequestedApiVersion();
55+
56+
return apiVersion?.MajorVersion;
57+
}
58+
59+
private int GetPropertyOrder(PropertyInfo prop)
60+
{
61+
var attribute = prop.GetCustomAttribute<JsonPropertyOrderAttribute>();
62+
return attribute?.Order ?? 0;
63+
}
64+
65+
/// <summary>
66+
/// Determines whether a property should be included based on version bounds.
67+
/// </summary>
68+
/// <param name="propertyInfo">The property info.</param>
69+
/// <param name="version">An integer representing an API version.</param>
70+
/// <returns><c>true</c> if the property should be included; otherwise, <c>false</c>.</returns>
71+
private bool ShouldIncludeProperty(PropertyInfo propertyInfo, int version)
72+
{
73+
var attribute = propertyInfo
74+
.GetCustomAttributes(typeof(IncludeInApiVersionAttribute), false)
75+
.FirstOrDefault();
76+
77+
if (attribute is not IncludeInApiVersionAttribute apiVersionAttribute)
78+
{
79+
return true; // No attribute means include the property
80+
}
81+
82+
// Check if the version is within the specified bounds
83+
var isWithinMinVersion = apiVersionAttribute.MinVersion.HasValue is false || version >= apiVersionAttribute.MinVersion.Value;
84+
var isWithinMaxVersion = apiVersionAttribute.MaxVersion.HasValue is false || version <= apiVersionAttribute.MaxVersion.Value;
85+
86+
return isWithinMinVersion && isWithinMaxVersion;
87+
}
88+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
namespace Umbraco.Cms.Core.DeliveryApi;
2+
3+
[AttributeUsage(AttributeTargets.Property)]
4+
public class IncludeInApiVersionAttribute : Attribute
5+
{
6+
public int? MinVersion { get; }
7+
8+
public int? MaxVersion { get; }
9+
10+
/// <summary>
11+
/// Initializes a new instance of the <see cref="IncludeInApiVersionAttribute"/> class.
12+
/// Specifies that the property should be included in the API response if the API version falls within the specified bounds.
13+
/// </summary>
14+
/// <param name="minVersion">The minimum API version (inclusive) for which the property should be included.</param>
15+
/// <param name="maxVersion">The maximum API version (inclusive) for which the property should be included.</param>
16+
public IncludeInApiVersionAttribute(int minVersion = -1, int maxVersion = -1)
17+
{
18+
MinVersion = minVersion >= 0 ? minVersion : null;
19+
MaxVersion = maxVersion >= 0 ? maxVersion : null;
20+
}
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
using Asp.Versioning;
4+
using Microsoft.AspNetCore.Http;
5+
using Moq;
6+
using NUnit.Framework;
7+
using Umbraco.Cms.Api.Delivery.Json;
8+
using Umbraco.Cms.Core.DeliveryApi;
9+
10+
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Delivery.Json;
11+
12+
[TestFixture]
13+
public class DeliveryApiVersionAwareJsonConverterBaseTests
14+
{
15+
private Mock<IHttpContextAccessor> _httpContextAccessorMock;
16+
private Mock<IApiVersioningFeature> _apiVersioningFeatureMock;
17+
18+
private void SetUpMocks(int apiVersion)
19+
{
20+
_httpContextAccessorMock = new Mock<IHttpContextAccessor>();
21+
_apiVersioningFeatureMock = new Mock<IApiVersioningFeature>();
22+
23+
_apiVersioningFeatureMock
24+
.SetupGet(feature => feature.RequestedApiVersion)
25+
.Returns(new ApiVersion(apiVersion));
26+
27+
var httpContext = new DefaultHttpContext();
28+
httpContext.Features.Set(_apiVersioningFeatureMock.Object);
29+
30+
_httpContextAccessorMock
31+
.SetupGet(accessor => accessor.HttpContext)
32+
.Returns(httpContext);
33+
}
34+
35+
[Test]
36+
[TestCase(1, new[] { "PropertyAll", "PropertyV1Max", "PropertyV2Max", "PropertyV2Only", "PropertyV2Min" })]
37+
[TestCase(2, new[] { "PropertyAll", "PropertyV1Max", "PropertyV2Max", "PropertyV2Only", "PropertyV2Min" })]
38+
[TestCase(3, new[] { "PropertyAll", "PropertyV1Max", "PropertyV2Max", "PropertyV2Only", "PropertyV2Min" })]
39+
public void Can_Include_All_Properties_When_HttpContext_Is_Not_Available(int apiVersion, string[] expectedPropertyNames)
40+
{
41+
// Arrange
42+
using var memoryStream = new MemoryStream();
43+
using var jsonWriter = new Utf8JsonWriter(memoryStream);
44+
45+
_httpContextAccessorMock = new Mock<IHttpContextAccessor>();
46+
_apiVersioningFeatureMock = new Mock<IApiVersioningFeature>();
47+
48+
_apiVersioningFeatureMock
49+
.SetupGet(feature => feature.RequestedApiVersion)
50+
.Returns(new ApiVersion(apiVersion));
51+
52+
_httpContextAccessorMock
53+
.SetupGet(accessor => accessor.HttpContext)
54+
.Returns((HttpContext)null);
55+
56+
var sut = new TestJsonConverter(_httpContextAccessorMock.Object);
57+
58+
// Act
59+
sut.Write(jsonWriter, new TestResponseModel(), new JsonSerializerOptions());
60+
jsonWriter.Flush();
61+
62+
memoryStream.Seek(0, SeekOrigin.Begin);
63+
using var reader = new StreamReader(memoryStream);
64+
var output = reader.ReadToEnd();
65+
66+
// Assert
67+
Assert.That(expectedPropertyNames.All(v => output.Contains(v, StringComparison.InvariantCulture)), Is.True);
68+
}
69+
70+
[Test]
71+
[TestCase(1, new[] { "PropertyAll", "PropertyV1Max", "PropertyV2Max" }, new[] { "PropertyV2Min", "PropertyV2Only" })]
72+
[TestCase(2, new[] { "PropertyAll", "PropertyV2Min", "PropertyV2Only", "PropertyV2Max" }, new[] { "PropertyV1Max" })]
73+
[TestCase(3, new[] { "PropertyAll", "PropertyV2Min" }, new[] { "PropertyV1Max", "PropertyV2Only", "PropertyV2Max" })]
74+
public void Can_Include_Correct_Properties_Based_On_Version_Attribute(int apiVersion, string[] expectedPropertyNames, string[] expectedDisallowedPropertyNames)
75+
{
76+
var jsonOptions = new JsonSerializerOptions();
77+
var output = GetJsonOutput(apiVersion, jsonOptions);
78+
79+
// Assert
80+
Assert.Multiple(() =>
81+
{
82+
Assert.That(expectedPropertyNames.All(v => output.Contains(v, StringComparison.InvariantCulture)), Is.True);
83+
Assert.That(expectedDisallowedPropertyNames.All(v => output.Contains(v, StringComparison.InvariantCulture) is false), Is.True);
84+
});
85+
}
86+
87+
[Test]
88+
[TestCase(1, new[] { "PropertyAll", "PropertyV1Max", "PropertyV2Max" })]
89+
[TestCase(2, new[] { "PropertyAll", "PropertyV2Min", "PropertyV2Only", "PropertyV2Max" })]
90+
[TestCase(3, new[] { "PropertyAll", "PropertyV2Min" })]
91+
public void Can_Serialize_Properties_Correctly_Based_On_Version_Attribute(int apiVersion, string[] expectedPropertyNames)
92+
{
93+
var jsonOptions = new JsonSerializerOptions();
94+
var output = GetJsonOutput(apiVersion, jsonOptions);
95+
96+
// Verify values correspond to properties
97+
var jsonDoc = JsonDocument.Parse(output);
98+
var root = jsonDoc.RootElement;
99+
100+
// Assert
101+
foreach (var propertyName in expectedPropertyNames)
102+
{
103+
var expectedValue = GetPropertyValue(propertyName);
104+
Assert.AreEqual(expectedValue, root.GetProperty(propertyName).GetString());
105+
}
106+
}
107+
108+
[Test]
109+
[TestCase(1, new[] { "propertyAll", "propertyV1Max", "propertyV2Max" }, new[] { "propertyV2Min", "propertyV2Only" })]
110+
[TestCase(2, new[] { "propertyAll", "propertyV2Min", "propertyV2Only", "propertyV2Max" }, new[] { "propertyV1Max" })]
111+
[TestCase(3, new[] { "propertyAll", "propertyV2Min" }, new[] { "propertyV1Max", "propertyV2Only", "propertyV2Max" })]
112+
public void Can_Respect_Property_Naming_Policy_On_Json_Options(int apiVersion, string[] expectedPropertyNames, string[] expectedDisallowedPropertyNames)
113+
{
114+
// Set up CamelCase naming policy
115+
var jsonOptions = new JsonSerializerOptions
116+
{
117+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
118+
};
119+
120+
var output = GetJsonOutput(apiVersion, jsonOptions);
121+
122+
// Assert
123+
Assert.Multiple(() =>
124+
{
125+
Assert.That(expectedPropertyNames.All(v => output.Contains(v, StringComparison.InvariantCulture)), Is.True);
126+
Assert.That(expectedDisallowedPropertyNames.All(v => output.Contains(v, StringComparison.InvariantCulture) is false), Is.True);
127+
});
128+
}
129+
130+
[Test]
131+
[TestCase(1, "PropertyV1Max", "PropertyAll")]
132+
[TestCase(2, "PropertyV2Min", "PropertyAll")]
133+
public void Can_Respect_Property_Order(int apiVersion, string expectedFirstPropertyName, string expectedLastPropertyName)
134+
{
135+
var jsonOptions = new JsonSerializerOptions();
136+
var output = GetJsonOutput(apiVersion, jsonOptions);
137+
138+
// Parse the JSON to verify the order of properties
139+
using var jsonDocument = JsonDocument.Parse(output);
140+
var rootElement = jsonDocument.RootElement;
141+
142+
var properties = rootElement.EnumerateObject().ToList();
143+
var firstProperty = properties.First();
144+
var lastProperty = properties.Last();
145+
146+
// Assert
147+
Assert.Multiple(() =>
148+
{
149+
Assert.AreEqual(expectedFirstPropertyName, firstProperty.Name);
150+
Assert.AreEqual(expectedLastPropertyName, lastProperty.Name);
151+
});
152+
}
153+
154+
private string GetJsonOutput(int apiVersion, JsonSerializerOptions jsonOptions)
155+
{
156+
// Arrange
157+
using var memoryStream = new MemoryStream();
158+
using var jsonWriter = new Utf8JsonWriter(memoryStream);
159+
160+
SetUpMocks(apiVersion);
161+
var sut = new TestJsonConverter(_httpContextAccessorMock.Object);
162+
163+
// Act
164+
sut.Write(jsonWriter, new TestResponseModel(), jsonOptions);
165+
jsonWriter.Flush();
166+
167+
memoryStream.Seek(0, SeekOrigin.Begin);
168+
using var reader = new StreamReader(memoryStream);
169+
170+
return reader.ReadToEnd();
171+
}
172+
173+
private string GetPropertyValue(string propertyName)
174+
{
175+
var model = new TestResponseModel();
176+
return propertyName switch
177+
{
178+
nameof(TestResponseModel.PropertyAll) => model.PropertyAll,
179+
nameof(TestResponseModel.PropertyV1Max) => model.PropertyV1Max,
180+
nameof(TestResponseModel.PropertyV2Max) => model.PropertyV2Max,
181+
nameof(TestResponseModel.PropertyV2Min) => model.PropertyV2Min,
182+
nameof(TestResponseModel.PropertyV2Only) => model.PropertyV2Only,
183+
_ => throw new ArgumentException($"Unknown property name: {propertyName}"),
184+
};
185+
}
186+
}
187+
188+
internal class TestJsonConverter : DeliveryApiVersionAwareJsonConverterBase<TestResponseModel>
189+
{
190+
public TestJsonConverter(IHttpContextAccessor httpContextAccessor)
191+
: base(httpContextAccessor)
192+
{
193+
}
194+
}
195+
196+
internal class TestResponseModel
197+
{
198+
[JsonPropertyOrder(100)]
199+
public string PropertyAll { get; init; } = "all";
200+
201+
[IncludeInApiVersion(maxVersion: 1)]
202+
public string PropertyV1Max { get; init; } = "v1";
203+
204+
[IncludeInApiVersion(2)]
205+
public string PropertyV2Min { get; init; } = "v2+";
206+
207+
[IncludeInApiVersion(2, 2)]
208+
public string PropertyV2Only { get; init; } = "v2";
209+
210+
[IncludeInApiVersion(maxVersion: 2)]
211+
public string PropertyV2Max { get; init; } = "up to v2";
212+
}

0 commit comments

Comments
 (0)