From fa0d79123a24bbafa07393ecf18f03e18862e097 Mon Sep 17 00:00:00 2001 From: Johannes Haukland <42615991+HauklandJ@users.noreply.github.com> Date: Mon, 7 Oct 2024 14:11:08 +0200 Subject: [PATCH] Use ApiConventions to solve enum serialization on assembly scope (#784) * new concept * remove confusing setup in test * fix warnings * implement canWriteResult on formatter * use JsonNumberEnumConverter instead of custom converter * use altinnapi as jsonsettings name * remove unused logger * use api conventions to add JsonSettingsNameAttribute attribute to controllers * use default json converter * Use UnsafeRelaxedJsonEscaping, change formatter ordering * Clarify comments * add constants for json setting names * pascalcase for const * use copy of deafult encoder * add tests * add the tests i forgot in the last commit * use the const instead of magic strings * cleanup unused usings * use defaults for serializeroptions * revert to add custom options * add tests * dispose all disposable, even in tests * add the final using statement --------- Co-authored-by: Martin Othamar --- .../Attributes/JsonSettingsNameAttribute.cs | 19 +++ .../Conventions/AltinnApiJsonFormatter.cs | 44 ++++++ .../AltinnControllerConventions.cs | 12 ++ .../Conventions/ConfigureMvcJsonOptions.cs | 45 ++++++ .../Extensions/HttpContextExtensions.cs | 11 ++ .../Extensions/MvcBuilderExtensions.cs | 28 ++++ .../Extensions/ServiceCollectionExtensions.cs | 10 ++ .../AltinnApiJsonFormatterTests.cs | 105 ++++++++++++++ .../AltinnControllerConventionTests.cs | 31 +++++ .../ConfigureMvcJsonOptionsTests.cs | 96 +++++++++++++ .../Conventions/EnumSerializationTests.cs | 131 ++++++++++++++++++ .../Extensions/HttpClientExtensionsTests.cs | 96 +++++++++++++ 12 files changed, 628 insertions(+) create mode 100644 src/Altinn.App.Api/Controllers/Attributes/JsonSettingsNameAttribute.cs create mode 100644 src/Altinn.App.Api/Controllers/Conventions/AltinnApiJsonFormatter.cs create mode 100644 src/Altinn.App.Api/Controllers/Conventions/AltinnControllerConventions.cs create mode 100644 src/Altinn.App.Api/Controllers/Conventions/ConfigureMvcJsonOptions.cs create mode 100644 src/Altinn.App.Api/Extensions/HttpContextExtensions.cs create mode 100644 src/Altinn.App.Api/Extensions/MvcBuilderExtensions.cs create mode 100644 test/Altinn.App.Api.Tests/Controllers/Conventions/AltinnApiJsonFormatterTests.cs create mode 100644 test/Altinn.App.Api.Tests/Controllers/Conventions/AltinnControllerConventionTests.cs create mode 100644 test/Altinn.App.Api.Tests/Controllers/Conventions/ConfigureMvcJsonOptionsTests.cs create mode 100644 test/Altinn.App.Api.Tests/Controllers/Conventions/EnumSerializationTests.cs create mode 100644 test/Altinn.App.Api.Tests/Extensions/HttpClientExtensionsTests.cs diff --git a/src/Altinn.App.Api/Controllers/Attributes/JsonSettingsNameAttribute.cs b/src/Altinn.App.Api/Controllers/Attributes/JsonSettingsNameAttribute.cs new file mode 100644 index 000000000..57e6fe199 --- /dev/null +++ b/src/Altinn.App.Api/Controllers/Attributes/JsonSettingsNameAttribute.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Altinn.App.Api.Controllers.Attributes; + +[AttributeUsage(AttributeTargets.Class)] +internal class JsonSettingsNameAttribute : Attribute, IFilterMetadata +{ + internal JsonSettingsNameAttribute(string name) + { + Name = name; + } + + internal string Name { get; } +} + +internal static class JsonSettingNames +{ + internal const string AltinnApi = "AltinnApi"; +} diff --git a/src/Altinn.App.Api/Controllers/Conventions/AltinnApiJsonFormatter.cs b/src/Altinn.App.Api/Controllers/Conventions/AltinnApiJsonFormatter.cs new file mode 100644 index 000000000..64da93ef3 --- /dev/null +++ b/src/Altinn.App.Api/Controllers/Conventions/AltinnApiJsonFormatter.cs @@ -0,0 +1,44 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using Altinn.App.Api.Extensions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace Altinn.App.Api.Controllers.Conventions; + +internal sealed class AltinnApiJsonFormatter : SystemTextJsonOutputFormatter +{ + private AltinnApiJsonFormatter(string settingsName, JsonSerializerOptions options) + : base(options) + { + SettingsName = settingsName; + } + + internal string SettingsName { get; } + + public override bool CanWriteResult(OutputFormatterCanWriteContext context) + { + if (context.HttpContext.GetJsonSettingsName() != SettingsName) + { + return false; + } + + return base.CanWriteResult(context); + } + + internal static AltinnApiJsonFormatter CreateFormatter(string settingsName, JsonOptions jsonOptions) + { + var jsonSerializerOptions = jsonOptions.JsonSerializerOptions; + + if (jsonSerializerOptions.Encoder is null) + { + // If the user hasn't explicitly configured the encoder, use the less strict encoder that does not encode all non-ASCII characters. + jsonSerializerOptions = new JsonSerializerOptions(jsonSerializerOptions) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + } + + return new AltinnApiJsonFormatter(settingsName, jsonSerializerOptions); + } +} diff --git a/src/Altinn.App.Api/Controllers/Conventions/AltinnControllerConventions.cs b/src/Altinn.App.Api/Controllers/Conventions/AltinnControllerConventions.cs new file mode 100644 index 000000000..d091dfc80 --- /dev/null +++ b/src/Altinn.App.Api/Controllers/Conventions/AltinnControllerConventions.cs @@ -0,0 +1,12 @@ +using Altinn.App.Api.Controllers.Attributes; +using Microsoft.AspNetCore.Mvc.ApplicationModels; + +namespace Altinn.App.Api.Controllers.Conventions; + +internal class AltinnControllerConventions : IControllerModelConvention +{ + public void Apply(ControllerModel controller) + { + controller.Filters.Add(new JsonSettingsNameAttribute(JsonSettingNames.AltinnApi)); + } +} diff --git a/src/Altinn.App.Api/Controllers/Conventions/ConfigureMvcJsonOptions.cs b/src/Altinn.App.Api/Controllers/Conventions/ConfigureMvcJsonOptions.cs new file mode 100644 index 000000000..4027c774a --- /dev/null +++ b/src/Altinn.App.Api/Controllers/Conventions/ConfigureMvcJsonOptions.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.Options; + +namespace Altinn.App.Api.Controllers.Conventions; + +/// +/// Configures MVC options to use a specific JSON serialization settings for enum-to-number conversion. +/// +public class ConfigureMvcJsonOptions : IConfigureOptions +{ + private readonly string _jsonSettingsName; + private readonly IOptionsMonitor _jsonOptions; + + /// + /// Initializes a new instance of the class. + /// + /// The name of the JSON settings to be used for enum-to-number conversion. + /// /// An to access the named JSON options. + public ConfigureMvcJsonOptions(string jsonSettingsName, IOptionsMonitor jsonOptions) + { + _jsonSettingsName = jsonSettingsName; + _jsonOptions = jsonOptions; + } + + /// + /// Configures the MVC options to use the for the specified JSON settings. + /// Makes sure to add to the formatter before the default . + /// + /// The to configure. + public void Configure(MvcOptions options) + { + var defaultJsonFormatter = + options.OutputFormatters.OfType().FirstOrDefault() + ?? throw new InvalidOperationException("Could not find the default JSON output formatter"); + + var indexOfDefaultJsonFormatter = options.OutputFormatters.IndexOf(defaultJsonFormatter); + + var jsonOptions = _jsonOptions.Get(_jsonSettingsName); + options.OutputFormatters.Insert( + indexOfDefaultJsonFormatter, + AltinnApiJsonFormatter.CreateFormatter(_jsonSettingsName, jsonOptions) + ); + } +} diff --git a/src/Altinn.App.Api/Extensions/HttpContextExtensions.cs b/src/Altinn.App.Api/Extensions/HttpContextExtensions.cs new file mode 100644 index 000000000..1609eef43 --- /dev/null +++ b/src/Altinn.App.Api/Extensions/HttpContextExtensions.cs @@ -0,0 +1,11 @@ +using Altinn.App.Api.Controllers.Attributes; + +namespace Altinn.App.Api.Extensions; + +internal static class HttpContextExtensions +{ + internal static string? GetJsonSettingsName(this HttpContext context) + { + return context.GetEndpoint()?.Metadata.GetMetadata()?.Name; + } +} diff --git a/src/Altinn.App.Api/Extensions/MvcBuilderExtensions.cs b/src/Altinn.App.Api/Extensions/MvcBuilderExtensions.cs new file mode 100644 index 000000000..877e8f429 --- /dev/null +++ b/src/Altinn.App.Api/Extensions/MvcBuilderExtensions.cs @@ -0,0 +1,28 @@ +using Altinn.App.Api.Controllers.Conventions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Altinn.App.Api.Extensions; + +internal static class MvcBuilderExtensions +{ + internal static IMvcBuilder AddJsonOptions( + this IMvcBuilder builder, + string settingsName, + Action configure + ) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configure); + + builder.Services.Configure(settingsName, configure); + + builder.Services.AddSingleton>(sp => + { + var options = sp.GetRequiredService>(); + return new ConfigureMvcJsonOptions(settingsName, options); + }); + + return builder; + } +} diff --git a/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs index 974be3360..a014ed7eb 100644 --- a/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.App.Api/Extensions/ServiceCollectionExtensions.cs @@ -1,5 +1,7 @@ using System.Diagnostics; using Altinn.App.Api.Controllers; +using Altinn.App.Api.Controllers.Attributes; +using Altinn.App.Api.Controllers.Conventions; using Altinn.App.Api.Helpers; using Altinn.App.Api.Infrastructure.Filters; using Altinn.App.Api.Infrastructure.Health; @@ -43,10 +45,18 @@ public static void AddAltinnAppControllersWithViews(this IServiceCollection serv IMvcBuilder mvcBuilder = services.AddControllersWithViews(options => { options.Filters.Add(); + options.Conventions.Add(new AltinnControllerConventions()); }); mvcBuilder .AddApplicationPart(typeof(InstancesController).Assembly) .AddXmlSerializerFormatters() + .AddJsonOptions( + JsonSettingNames.AltinnApi, + options => + { + options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; + } + ) .AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; diff --git a/test/Altinn.App.Api.Tests/Controllers/Conventions/AltinnApiJsonFormatterTests.cs b/test/Altinn.App.Api.Tests/Controllers/Conventions/AltinnApiJsonFormatterTests.cs new file mode 100644 index 000000000..617e992db --- /dev/null +++ b/test/Altinn.App.Api.Tests/Controllers/Conventions/AltinnApiJsonFormatterTests.cs @@ -0,0 +1,105 @@ +using System.Text.Encodings.Web; +using System.Text.Json.Serialization.Metadata; +using Altinn.App.Api.Controllers.Attributes; +using Altinn.App.Api.Controllers.Conventions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Formatters; + +namespace Altinn.App.Api.Tests.Controllers.Conventions; + +public class AltinnApiJsonFormatterTests +{ + [Fact] + public void CreateFormatter_WhenEncoderIsNotNull_PreservesEncoder() + { + // Arrange + string settingsName = JsonSettingNames.AltinnApi; + var originalEncoder = JavaScriptEncoder.Default; + + var jsonOptions = new JsonOptions(); + jsonOptions.JsonSerializerOptions.Encoder = originalEncoder; + jsonOptions.JsonSerializerOptions.TypeInfoResolver = new DefaultJsonTypeInfoResolver(); + + // Act + var formatter = AltinnApiJsonFormatter.CreateFormatter(settingsName, jsonOptions); + + // Assert + Assert.NotNull(formatter); + Assert.Equal(settingsName, formatter.SettingsName); + Assert.Equal(originalEncoder, formatter.SerializerOptions.Encoder); + } + + [Fact] + public void CanWriteResult_SettingsNameMatches_ReturnsTrue() + { + // Arrange + string settingsName = JsonSettingNames.AltinnApi; + + var jsonOptions = new JsonOptions(); + jsonOptions.JsonSerializerOptions.TypeInfoResolver = new DefaultJsonTypeInfoResolver(); + + var formatter = AltinnApiJsonFormatter.CreateFormatter(settingsName, jsonOptions); + + var httpContext = new DefaultHttpContext(); + + // Create an Endpoint with JsonSettingsNameAttribute + var endpoint = new Endpoint( + requestDelegate: null, + metadata: new EndpointMetadataCollection(new JsonSettingsNameAttribute(settingsName)), + displayName: null + ); + + httpContext.SetEndpoint(endpoint); + + var context = new OutputFormatterWriteContext( + httpContext, + (stream, encoding) => new StreamWriter(stream, encoding), + typeof(object), + new object() + ); + + // Act + bool canWrite = formatter.CanWriteResult(context); + + // Assert + Assert.True(canWrite); + } + + [Fact] + public void CanWriteResult_SettingsNameMisMatch_ReturnsFalse() + { + // Arrange + string formatterSettingsName = "FormatterSettingName"; + string endpointSettingsName = "EndpointSettingName"; + + var jsonOptions = new JsonOptions(); + jsonOptions.JsonSerializerOptions.TypeInfoResolver = new DefaultJsonTypeInfoResolver(); + + var formatter = AltinnApiJsonFormatter.CreateFormatter(formatterSettingsName, jsonOptions); + + var httpContext = new DefaultHttpContext(); + + // Create an Endpoint with JsonSettingsNameAttribute with a different name + var endpoint = new Endpoint( + requestDelegate: null, + metadata: new EndpointMetadataCollection(new JsonSettingsNameAttribute(endpointSettingsName)), + displayName: null + ); + + httpContext.SetEndpoint(endpoint); + + var context = new OutputFormatterWriteContext( + httpContext, + (stream, encoding) => new StreamWriter(stream, encoding), + typeof(object), + new object() + ); + + // Act + bool canWrite = formatter.CanWriteResult(context); + + // Assert + Assert.False(canWrite); + } +} diff --git a/test/Altinn.App.Api.Tests/Controllers/Conventions/AltinnControllerConventionTests.cs b/test/Altinn.App.Api.Tests/Controllers/Conventions/AltinnControllerConventionTests.cs new file mode 100644 index 000000000..c183cc109 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Controllers/Conventions/AltinnControllerConventionTests.cs @@ -0,0 +1,31 @@ +using System.Reflection; +using Altinn.App.Api.Controllers.Attributes; +using Altinn.App.Api.Controllers.Conventions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationModels; + +namespace Altinn.App.Api.Tests.Controllers.Conventions; + +public class AltinnControllerConventionsTests +{ + [Fact] + public void Apply_AddsJsonSettingsNameAttributeToControllerModel() + { + // Arrange + var convention = new AltinnControllerConventions(); + var controllerType = typeof(TestController).GetTypeInfo(); + var controllerModel = new ControllerModel(controllerType, []); + + // Act + convention.Apply(controllerModel); + + // Assert + var attribute = controllerModel.Filters.OfType().FirstOrDefault(); + + Assert.NotNull(attribute); + Assert.Equal(JsonSettingNames.AltinnApi, attribute.Name); + } + + // Dummy controller + private class TestController : ControllerBase { } +} diff --git a/test/Altinn.App.Api.Tests/Controllers/Conventions/ConfigureMvcJsonOptionsTests.cs b/test/Altinn.App.Api.Tests/Controllers/Conventions/ConfigureMvcJsonOptionsTests.cs new file mode 100644 index 000000000..da4340d00 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Controllers/Conventions/ConfigureMvcJsonOptionsTests.cs @@ -0,0 +1,96 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using Altinn.App.Api.Controllers.Attributes; +using Altinn.App.Api.Controllers.Conventions; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Formatters; +using Microsoft.Extensions.Options; + +namespace Altinn.App.Api.Tests.Controllers.Conventions; + +public class ConfigureMvcJsonOptionsTests +{ + [Fact] + public void Configure_InsertsCustomFormatterWithCorrectSettings() + { + // Arrange + var jsonSettingsName = JsonSettingNames.AltinnApi; + ConfigureMvcJsonOptions configureOptions = GetConfigureOptionsForTest(jsonSettingsName); + var mvcOptions = new MvcOptions(); + + // Create default JsonSerializerOptions with JsonStringEnumConverter + var defaultSerializerOptions = new JsonSerializerOptions(); + defaultSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + defaultSerializerOptions.Encoder = JavaScriptEncoder.Default; + defaultSerializerOptions.TypeInfoResolver = new DefaultJsonTypeInfoResolver(); + + // Add the default SystemTextJsonOutputFormatter + var defaultJsonFormatter = new SystemTextJsonOutputFormatter(defaultSerializerOptions); + mvcOptions.OutputFormatters.Add(defaultJsonFormatter); + + // Act + configureOptions.Configure(mvcOptions); + + // Assert + var customFormatter = mvcOptions.OutputFormatters.OfType().FirstOrDefault(); + + Assert.NotNull(customFormatter); + Assert.Equal(jsonSettingsName, customFormatter.SettingsName); + + var indexOfDefaultFormatter = mvcOptions.OutputFormatters.IndexOf(defaultJsonFormatter); + var indexOfCustomFormatter = mvcOptions.OutputFormatters.IndexOf(customFormatter); + + Assert.Equal(indexOfDefaultFormatter - 1, indexOfCustomFormatter); + + var customSerializerOptions = customFormatter.SerializerOptions; + var hasEnumConverter = customSerializerOptions.Converters.Any(c => c is JsonStringEnumConverter); + + Assert.False( + hasEnumConverter, + "JsonStringEnumConverter should have been removed from the custom formatter's SerializerOptions" + ); + + Assert.NotNull(customSerializerOptions.Encoder); + } + + [Fact] + public void Configure_NoDefaultOutputFormatter_ThrowsInvalidOperationException() + { + ConfigureMvcJsonOptions configureOptions = GetConfigureOptionsForTest(JsonSettingNames.AltinnApi); + + var mvcOptions = new MvcOptions(); + + Assert.Throws(() => configureOptions.Configure(mvcOptions)); + } + + private static ConfigureMvcJsonOptions GetConfigureOptionsForTest(string jsonSettingsName) + { + var jsonOptions = new JsonOptions(); + jsonOptions.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + jsonOptions.JsonSerializerOptions.TypeInfoResolver = new DefaultJsonTypeInfoResolver(); + + var optionsMonitor = new TestOptionsMonitor(jsonOptions); + var configureOptions = new ConfigureMvcJsonOptions(jsonSettingsName, optionsMonitor); + return configureOptions; + } +} + +public class TestOptionsMonitor : IOptionsMonitor +{ + public TestOptionsMonitor(TOptions currentValue) + { + CurrentValue = currentValue; + } + + public TOptions CurrentValue { get; } + + public TOptions Get(string? name) => CurrentValue; + + public IDisposable OnChange(Action listener) + { + // No-op for testing purposes + return null!; + } +} diff --git a/test/Altinn.App.Api.Tests/Controllers/Conventions/EnumSerializationTests.cs b/test/Altinn.App.Api.Tests/Controllers/Conventions/EnumSerializationTests.cs new file mode 100644 index 000000000..ce6c31f1f --- /dev/null +++ b/test/Altinn.App.Api.Tests/Controllers/Conventions/EnumSerializationTests.cs @@ -0,0 +1,131 @@ +using System.Net; +using System.Text.Json; +using System.Text.Json.Serialization; +using Altinn.App.Api.Extensions; +using Altinn.App.Core.Internal.App; +using Altinn.App.Core.Internal.Auth; +using Altinn.App.Core.Models; +using Altinn.Platform.Register.Enums; +using Altinn.Platform.Storage.Interface.Models; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit.Abstractions; + +namespace Altinn.App.Api.Tests.Controllers.Conventions; + +public class EnumSerializationTests : ApiTestBase, IClassFixture> +{ + private const string Org = "tdd"; + private const string App = "contributer-restriction"; + private const int PartyId = 500600; + + private readonly Mock _authorizationClientMock; + private readonly Mock _appMetadataMock; + + public EnumSerializationTests(WebApplicationFactory factory, ITestOutputHelper outputHelper) + : base(factory, outputHelper) + { + // Mock auth client to return the enum we want to test + _authorizationClientMock = new Mock(); + _authorizationClientMock + .Setup(a => a.GetPartyList(It.IsAny())) + .ReturnsAsync([new() { PartyTypeName = PartyType.Person }]); + + _appMetadataMock = new Mock(); + _appMetadataMock + .Setup(s => s.GetApplicationMetadata()) + .ReturnsAsync( + new ApplicationMetadata(id: "ttd/test") { PartyTypesAllowed = new PartyTypesAllowed { Person = true } } + ); + + OverrideServicesForAllTests = (services) => + { + services + .AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new CustomConverterFactory()); + options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); + }); + + services.AddScoped(_ => _authorizationClientMock.Object); + services.AddScoped(_ => _appMetadataMock.Object); + }; + } + + [Fact] + public async Task ValidateInstantiation_SerializesPartyTypesAllowedAsNumber() + { + // Arrange + using var client = GetRootedClient(Org, App, 1337, PartyId); + + // Act + var response = await client.PostAsync( + $"{Org}/{App}/api/v1/parties/validateInstantiation?partyId={PartyId}", + null + ); + response.Should().HaveStatusCode(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + + var partyTypeEnumJson = JsonDocument + .Parse(content) + .RootElement.GetProperty("validParties") + .EnumerateArray() + .First() + .GetProperty("partyTypeName"); + + // Assert + partyTypeEnumJson.Should().NotBeNull(); + partyTypeEnumJson.TryGetInt32(out var partyTypeJsonValue); + partyTypeJsonValue.Should().Be(1, "PartyTypesAllowed should be serialized as its numeric value"); + } +} + +public class CustomConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) => typeToConvert is not null; + + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var converterType = typeof(CustomConverter<>).MakeGenericType(typeToConvert); + return (JsonConverter)Activator.CreateInstance(converterType)!; + } +} + +public class CustomConverter : JsonConverter +{ + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (typeof(T).IsEnum) + { + var enumString = reader.GetString(); + + if (Enum.TryParse(typeof(T), enumString, out var enumValue)) + { + return (T)enumValue; + } + else + { + throw new JsonException($"Unable to convert \"{enumString}\" to enum \"{typeof(T)}\"."); + } + } + else + { + return JsonSerializer.Deserialize(ref reader, options); + } + } + + public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) + { + if (value is Enum) + { + writer.WriteStringValue(value.ToString()); + } + else + { + JsonSerializer.Serialize(writer, value, value!.GetType(), options); + } + } +} diff --git a/test/Altinn.App.Api.Tests/Extensions/HttpClientExtensionsTests.cs b/test/Altinn.App.Api.Tests/Extensions/HttpClientExtensionsTests.cs new file mode 100644 index 000000000..ed5c2ddc7 --- /dev/null +++ b/test/Altinn.App.Api.Tests/Extensions/HttpClientExtensionsTests.cs @@ -0,0 +1,96 @@ +namespace Altinn.App.Api.Tests.Extensions; + +public class HttpClientExtensionsTests +{ + [Fact] + public void GetDelegatingHandler_HandlerFound_ReturnsHandler() + { + // Arrange + var primaryHandler = new HttpClientHandler(); + var targetHandler = new CustomDelegatingHandler(primaryHandler); + using var httpClient = new HttpClient(targetHandler); + + // Act + var result = httpClient.GetDelegatingHandler(); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } + + [Fact] + public void GetDelegatingHandler_HandlerNotFound_ReturnsNull() + { + // Arrange + var primaryHandler = new HttpClientHandler(); + var someOtherHandler = new DelegatingHandlerStub(primaryHandler); + using var httpClient = new HttpClient(someOtherHandler); + + // Act + var result = httpClient.GetDelegatingHandler(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetDelegatingHandler_InitialHandlerIsNotDelegatingHandler_ReturnsNull() + { + // Arrange + using var httpClient = new HttpClient(new HttpClientHandler()); + + // Act + var result = httpClient.GetDelegatingHandler(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetDelegatingHandler_InnerHandlerIsNotDelegatingHandler_ReturnsNull() + { + // Arrange + var primaryHandler = new HttpClientHandler(); + var outerHandler = new DelegatingHandlerStub(primaryHandler); + using var httpClient = new HttpClient(outerHandler); + + // Act + var result = httpClient.GetDelegatingHandler(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetDelegatingHandler_MultipleHandlersInChain_ReturnsCorrectHandler() + { + // Arrange + var primaryHandler = new HttpClientHandler(); + var innerHandler = new DelegatingHandlerStub(primaryHandler); + var targetHandler = new CustomDelegatingHandler(innerHandler); + using var httpClient = new HttpClient(targetHandler); + + // Act + var result = httpClient.GetDelegatingHandler(); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } +} + +public class CustomDelegatingHandler : DelegatingHandler +{ + public CustomDelegatingHandler(HttpMessageHandler innerHandler) + { + InnerHandler = innerHandler; + } +} + +public class DelegatingHandlerStub : DelegatingHandler +{ + public DelegatingHandlerStub(HttpMessageHandler innerHandler) + { + InnerHandler = innerHandler; + } +}