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
This commit is contained in:
committed by
GitHub
parent
d451390d78
commit
32d0cb477e
@@ -0,0 +1,88 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
|
||||
namespace Umbraco.Cms.Api.Delivery.Json;
|
||||
|
||||
public abstract class DeliveryApiVersionAwareJsonConverterBase<T> : JsonConverter<T>
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly JsonConverter<T> _defaultConverter = (JsonConverter<T>)JsonSerializerOptions.Default.GetConverter(typeof(T));
|
||||
|
||||
public DeliveryApiVersionAwareJsonConverterBase(IHttpContextAccessor httpContextAccessor)
|
||||
=> _httpContextAccessor = httpContextAccessor;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> _defaultConverter.Read(ref reader, typeToConvert, options);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
|
||||
{
|
||||
Type type = typeof(T);
|
||||
var apiVersion = GetApiVersion();
|
||||
|
||||
// Get the properties in the specified order
|
||||
PropertyInfo[] properties = type.GetProperties().OrderBy(GetPropertyOrder).ToArray();
|
||||
|
||||
writer.WriteStartObject();
|
||||
|
||||
foreach (PropertyInfo property in properties)
|
||||
{
|
||||
// Filter out properties based on the API version
|
||||
var include = apiVersion is null || ShouldIncludeProperty(property, apiVersion.Value);
|
||||
|
||||
if (include is false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var propertyName = property.Name;
|
||||
writer.WritePropertyName(options.PropertyNamingPolicy?.ConvertName(propertyName) ?? propertyName);
|
||||
JsonSerializer.Serialize(writer, property.GetValue(value), options);
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
|
||||
private int? GetApiVersion()
|
||||
{
|
||||
HttpContext? httpContext = _httpContextAccessor.HttpContext;
|
||||
ApiVersion? apiVersion = httpContext?.GetRequestedApiVersion();
|
||||
|
||||
return apiVersion?.MajorVersion;
|
||||
}
|
||||
|
||||
private int GetPropertyOrder(PropertyInfo prop)
|
||||
{
|
||||
var attribute = prop.GetCustomAttribute<JsonPropertyOrderAttribute>();
|
||||
return attribute?.Order ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a property should be included based on version bounds.
|
||||
/// </summary>
|
||||
/// <param name="propertyInfo">The property info.</param>
|
||||
/// <param name="version">An integer representing an API version.</param>
|
||||
/// <returns><c>true</c> if the property should be included; otherwise, <c>false</c>.</returns>
|
||||
private bool ShouldIncludeProperty(PropertyInfo propertyInfo, int version)
|
||||
{
|
||||
var attribute = propertyInfo
|
||||
.GetCustomAttributes(typeof(IncludeInApiVersionAttribute), false)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (attribute is not IncludeInApiVersionAttribute apiVersionAttribute)
|
||||
{
|
||||
return true; // No attribute means include the property
|
||||
}
|
||||
|
||||
// Check if the version is within the specified bounds
|
||||
var isWithinMinVersion = apiVersionAttribute.MinVersion.HasValue is false || version >= apiVersionAttribute.MinVersion.Value;
|
||||
var isWithinMaxVersion = apiVersionAttribute.MaxVersion.HasValue is false || version <= apiVersionAttribute.MaxVersion.Value;
|
||||
|
||||
return isWithinMinVersion && isWithinMaxVersion;
|
||||
}
|
||||
}
|
||||
21
src/Umbraco.Core/DeliveryApi/IncludeInApiVersionAttribute.cs
Normal file
21
src/Umbraco.Core/DeliveryApi/IncludeInApiVersionAttribute.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace Umbraco.Cms.Core.DeliveryApi;
|
||||
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public class IncludeInApiVersionAttribute : Attribute
|
||||
{
|
||||
public int? MinVersion { get; }
|
||||
|
||||
public int? MaxVersion { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="IncludeInApiVersionAttribute"/> class.
|
||||
/// Specifies that the property should be included in the API response if the API version falls within the specified bounds.
|
||||
/// </summary>
|
||||
/// <param name="minVersion">The minimum API version (inclusive) for which the property should be included.</param>
|
||||
/// <param name="maxVersion">The maximum API version (inclusive) for which the property should be included.</param>
|
||||
public IncludeInApiVersionAttribute(int minVersion = -1, int maxVersion = -1)
|
||||
{
|
||||
MinVersion = minVersion >= 0 ? minVersion : null;
|
||||
MaxVersion = maxVersion >= 0 ? maxVersion : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Asp.Versioning;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Umbraco.Cms.Api.Delivery.Json;
|
||||
using Umbraco.Cms.Core.DeliveryApi;
|
||||
|
||||
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Cms.Api.Delivery.Json;
|
||||
|
||||
[TestFixture]
|
||||
public class DeliveryApiVersionAwareJsonConverterBaseTests
|
||||
{
|
||||
private Mock<IHttpContextAccessor> _httpContextAccessorMock;
|
||||
private Mock<IApiVersioningFeature> _apiVersioningFeatureMock;
|
||||
|
||||
private void SetUpMocks(int apiVersion)
|
||||
{
|
||||
_httpContextAccessorMock = new Mock<IHttpContextAccessor>();
|
||||
_apiVersioningFeatureMock = new Mock<IApiVersioningFeature>();
|
||||
|
||||
_apiVersioningFeatureMock
|
||||
.SetupGet(feature => feature.RequestedApiVersion)
|
||||
.Returns(new ApiVersion(apiVersion));
|
||||
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Features.Set(_apiVersioningFeatureMock.Object);
|
||||
|
||||
_httpContextAccessorMock
|
||||
.SetupGet(accessor => accessor.HttpContext)
|
||||
.Returns(httpContext);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(1, new[] { "PropertyAll", "PropertyV1Max", "PropertyV2Max", "PropertyV2Only", "PropertyV2Min" })]
|
||||
[TestCase(2, new[] { "PropertyAll", "PropertyV1Max", "PropertyV2Max", "PropertyV2Only", "PropertyV2Min" })]
|
||||
[TestCase(3, new[] { "PropertyAll", "PropertyV1Max", "PropertyV2Max", "PropertyV2Only", "PropertyV2Min" })]
|
||||
public void Can_Include_All_Properties_When_HttpContext_Is_Not_Available(int apiVersion, string[] expectedPropertyNames)
|
||||
{
|
||||
// Arrange
|
||||
using var memoryStream = new MemoryStream();
|
||||
using var jsonWriter = new Utf8JsonWriter(memoryStream);
|
||||
|
||||
_httpContextAccessorMock = new Mock<IHttpContextAccessor>();
|
||||
_apiVersioningFeatureMock = new Mock<IApiVersioningFeature>();
|
||||
|
||||
_apiVersioningFeatureMock
|
||||
.SetupGet(feature => feature.RequestedApiVersion)
|
||||
.Returns(new ApiVersion(apiVersion));
|
||||
|
||||
_httpContextAccessorMock
|
||||
.SetupGet(accessor => accessor.HttpContext)
|
||||
.Returns((HttpContext)null);
|
||||
|
||||
var sut = new TestJsonConverter(_httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
sut.Write(jsonWriter, new TestResponseModel(), new JsonSerializerOptions());
|
||||
jsonWriter.Flush();
|
||||
|
||||
memoryStream.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(memoryStream);
|
||||
var output = reader.ReadToEnd();
|
||||
|
||||
// Assert
|
||||
Assert.That(expectedPropertyNames.All(v => output.Contains(v, StringComparison.InvariantCulture)), Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(1, new[] { "PropertyAll", "PropertyV1Max", "PropertyV2Max" }, new[] { "PropertyV2Min", "PropertyV2Only" })]
|
||||
[TestCase(2, new[] { "PropertyAll", "PropertyV2Min", "PropertyV2Only", "PropertyV2Max" }, new[] { "PropertyV1Max" })]
|
||||
[TestCase(3, new[] { "PropertyAll", "PropertyV2Min" }, new[] { "PropertyV1Max", "PropertyV2Only", "PropertyV2Max" })]
|
||||
public void Can_Include_Correct_Properties_Based_On_Version_Attribute(int apiVersion, string[] expectedPropertyNames, string[] expectedDisallowedPropertyNames)
|
||||
{
|
||||
var jsonOptions = new JsonSerializerOptions();
|
||||
var output = GetJsonOutput(apiVersion, jsonOptions);
|
||||
|
||||
// Assert
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(expectedPropertyNames.All(v => output.Contains(v, StringComparison.InvariantCulture)), Is.True);
|
||||
Assert.That(expectedDisallowedPropertyNames.All(v => output.Contains(v, StringComparison.InvariantCulture) is false), Is.True);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(1, new[] { "PropertyAll", "PropertyV1Max", "PropertyV2Max" })]
|
||||
[TestCase(2, new[] { "PropertyAll", "PropertyV2Min", "PropertyV2Only", "PropertyV2Max" })]
|
||||
[TestCase(3, new[] { "PropertyAll", "PropertyV2Min" })]
|
||||
public void Can_Serialize_Properties_Correctly_Based_On_Version_Attribute(int apiVersion, string[] expectedPropertyNames)
|
||||
{
|
||||
var jsonOptions = new JsonSerializerOptions();
|
||||
var output = GetJsonOutput(apiVersion, jsonOptions);
|
||||
|
||||
// Verify values correspond to properties
|
||||
var jsonDoc = JsonDocument.Parse(output);
|
||||
var root = jsonDoc.RootElement;
|
||||
|
||||
// Assert
|
||||
foreach (var propertyName in expectedPropertyNames)
|
||||
{
|
||||
var expectedValue = GetPropertyValue(propertyName);
|
||||
Assert.AreEqual(expectedValue, root.GetProperty(propertyName).GetString());
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(1, new[] { "propertyAll", "propertyV1Max", "propertyV2Max" }, new[] { "propertyV2Min", "propertyV2Only" })]
|
||||
[TestCase(2, new[] { "propertyAll", "propertyV2Min", "propertyV2Only", "propertyV2Max" }, new[] { "propertyV1Max" })]
|
||||
[TestCase(3, new[] { "propertyAll", "propertyV2Min" }, new[] { "propertyV1Max", "propertyV2Only", "propertyV2Max" })]
|
||||
public void Can_Respect_Property_Naming_Policy_On_Json_Options(int apiVersion, string[] expectedPropertyNames, string[] expectedDisallowedPropertyNames)
|
||||
{
|
||||
// Set up CamelCase naming policy
|
||||
var jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
var output = GetJsonOutput(apiVersion, jsonOptions);
|
||||
|
||||
// Assert
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(expectedPropertyNames.All(v => output.Contains(v, StringComparison.InvariantCulture)), Is.True);
|
||||
Assert.That(expectedDisallowedPropertyNames.All(v => output.Contains(v, StringComparison.InvariantCulture) is false), Is.True);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(1, "PropertyV1Max", "PropertyAll")]
|
||||
[TestCase(2, "PropertyV2Min", "PropertyAll")]
|
||||
public void Can_Respect_Property_Order(int apiVersion, string expectedFirstPropertyName, string expectedLastPropertyName)
|
||||
{
|
||||
var jsonOptions = new JsonSerializerOptions();
|
||||
var output = GetJsonOutput(apiVersion, jsonOptions);
|
||||
|
||||
// Parse the JSON to verify the order of properties
|
||||
using var jsonDocument = JsonDocument.Parse(output);
|
||||
var rootElement = jsonDocument.RootElement;
|
||||
|
||||
var properties = rootElement.EnumerateObject().ToList();
|
||||
var firstProperty = properties.First();
|
||||
var lastProperty = properties.Last();
|
||||
|
||||
// Assert
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.AreEqual(expectedFirstPropertyName, firstProperty.Name);
|
||||
Assert.AreEqual(expectedLastPropertyName, lastProperty.Name);
|
||||
});
|
||||
}
|
||||
|
||||
private string GetJsonOutput(int apiVersion, JsonSerializerOptions jsonOptions)
|
||||
{
|
||||
// Arrange
|
||||
using var memoryStream = new MemoryStream();
|
||||
using var jsonWriter = new Utf8JsonWriter(memoryStream);
|
||||
|
||||
SetUpMocks(apiVersion);
|
||||
var sut = new TestJsonConverter(_httpContextAccessorMock.Object);
|
||||
|
||||
// Act
|
||||
sut.Write(jsonWriter, new TestResponseModel(), jsonOptions);
|
||||
jsonWriter.Flush();
|
||||
|
||||
memoryStream.Seek(0, SeekOrigin.Begin);
|
||||
using var reader = new StreamReader(memoryStream);
|
||||
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
private string GetPropertyValue(string propertyName)
|
||||
{
|
||||
var model = new TestResponseModel();
|
||||
return propertyName switch
|
||||
{
|
||||
nameof(TestResponseModel.PropertyAll) => model.PropertyAll,
|
||||
nameof(TestResponseModel.PropertyV1Max) => model.PropertyV1Max,
|
||||
nameof(TestResponseModel.PropertyV2Max) => model.PropertyV2Max,
|
||||
nameof(TestResponseModel.PropertyV2Min) => model.PropertyV2Min,
|
||||
nameof(TestResponseModel.PropertyV2Only) => model.PropertyV2Only,
|
||||
_ => throw new ArgumentException($"Unknown property name: {propertyName}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal class TestJsonConverter : DeliveryApiVersionAwareJsonConverterBase<TestResponseModel>
|
||||
{
|
||||
public TestJsonConverter(IHttpContextAccessor httpContextAccessor)
|
||||
: base(httpContextAccessor)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal class TestResponseModel
|
||||
{
|
||||
[JsonPropertyOrder(100)]
|
||||
public string PropertyAll { get; init; } = "all";
|
||||
|
||||
[IncludeInApiVersion(maxVersion: 1)]
|
||||
public string PropertyV1Max { get; init; } = "v1";
|
||||
|
||||
[IncludeInApiVersion(2)]
|
||||
public string PropertyV2Min { get; init; } = "v2+";
|
||||
|
||||
[IncludeInApiVersion(2, 2)]
|
||||
public string PropertyV2Only { get; init; } = "v2";
|
||||
|
||||
[IncludeInApiVersion(maxVersion: 2)]
|
||||
public string PropertyV2Max { get; init; } = "up to v2";
|
||||
}
|
||||
Reference in New Issue
Block a user