From 32d0cb477e465123f4149609b51c28ae167f632c Mon Sep 17 00:00:00 2001 From: Elitsa Marinovska <21998037+elit0451@users.noreply.github.com> Date: Mon, 16 Dec 2024 12:34:55 +0200 Subject: [PATCH] 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 --- ...eliveryApiVersionAwareJsonConverterBase.cs | 88 ++++++++ .../IncludeInApiVersionAttribute.cs | 21 ++ ...ryApiVersionAwareJsonConverterBaseTests.cs | 212 ++++++++++++++++++ 3 files changed, 321 insertions(+) create mode 100644 src/Umbraco.Cms.Api.Delivery/Json/DeliveryApiVersionAwareJsonConverterBase.cs create mode 100644 src/Umbraco.Core/DeliveryApi/IncludeInApiVersionAttribute.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Delivery/Json/DeliveryApiVersionAwareJsonConverterBaseTests.cs diff --git a/src/Umbraco.Cms.Api.Delivery/Json/DeliveryApiVersionAwareJsonConverterBase.cs b/src/Umbraco.Cms.Api.Delivery/Json/DeliveryApiVersionAwareJsonConverterBase.cs new file mode 100644 index 0000000000..93b5b9f49b --- /dev/null +++ b/src/Umbraco.Cms.Api.Delivery/Json/DeliveryApiVersionAwareJsonConverterBase.cs @@ -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 : JsonConverter +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly JsonConverter _defaultConverter = (JsonConverter)JsonSerializerOptions.Default.GetConverter(typeof(T)); + + public DeliveryApiVersionAwareJsonConverterBase(IHttpContextAccessor httpContextAccessor) + => _httpContextAccessor = httpContextAccessor; + + /// + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => _defaultConverter.Read(ref reader, typeToConvert, options); + + /// + 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(); + return attribute?.Order ?? 0; + } + + /// + /// Determines whether a property should be included based on version bounds. + /// + /// The property info. + /// An integer representing an API version. + /// true if the property should be included; otherwise, false. + 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; + } +} diff --git a/src/Umbraco.Core/DeliveryApi/IncludeInApiVersionAttribute.cs b/src/Umbraco.Core/DeliveryApi/IncludeInApiVersionAttribute.cs new file mode 100644 index 0000000000..f336126b82 --- /dev/null +++ b/src/Umbraco.Core/DeliveryApi/IncludeInApiVersionAttribute.cs @@ -0,0 +1,21 @@ +namespace Umbraco.Cms.Core.DeliveryApi; + +[AttributeUsage(AttributeTargets.Property)] +public class IncludeInApiVersionAttribute : Attribute +{ + public int? MinVersion { get; } + + public int? MaxVersion { get; } + + /// + /// Initializes a new instance of the class. + /// Specifies that the property should be included in the API response if the API version falls within the specified bounds. + /// + /// The minimum API version (inclusive) for which the property should be included. + /// The maximum API version (inclusive) for which the property should be included. + public IncludeInApiVersionAttribute(int minVersion = -1, int maxVersion = -1) + { + MinVersion = minVersion >= 0 ? minVersion : null; + MaxVersion = maxVersion >= 0 ? maxVersion : null; + } +} diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Delivery/Json/DeliveryApiVersionAwareJsonConverterBaseTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Delivery/Json/DeliveryApiVersionAwareJsonConverterBaseTests.cs new file mode 100644 index 0000000000..53c1b66fc2 --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Cms.Api.Delivery/Json/DeliveryApiVersionAwareJsonConverterBaseTests.cs @@ -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 _httpContextAccessorMock; + private Mock _apiVersioningFeatureMock; + + private void SetUpMocks(int apiVersion) + { + _httpContextAccessorMock = new Mock(); + _apiVersioningFeatureMock = new Mock(); + + _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(); + _apiVersioningFeatureMock = new Mock(); + + _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 +{ + 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"; +}