From a4ecfba74acf330df62b359ecd04f5d775e6f4a0 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:54:21 +0100 Subject: [PATCH 01/18] Eureka: Add unit test for ToString on JSON objects --- .../Transport/DebugSerializerOptions.cs | 6 +- .../AppInfo/ApplicationInfoCollectionTest.cs | 126 ++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/src/Discovery/src/Eureka/Transport/DebugSerializerOptions.cs b/src/Discovery/src/Eureka/Transport/DebugSerializerOptions.cs index 4a75651bb1..4aed5c3ec7 100644 --- a/src/Discovery/src/Eureka/Transport/DebugSerializerOptions.cs +++ b/src/Discovery/src/Eureka/Transport/DebugSerializerOptions.cs @@ -15,6 +15,10 @@ internal static class DebugSerializerOptions WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - ReferenceHandler = ReferenceHandler.IgnoreCycles + ReferenceHandler = ReferenceHandler.IgnoreCycles, + Converters = + { + new JsonStringEnumConverter() + } }; } diff --git a/src/Discovery/test/Eureka.Test/AppInfo/ApplicationInfoCollectionTest.cs b/src/Discovery/test/Eureka.Test/AppInfo/ApplicationInfoCollectionTest.cs index 6648a50ce8..c1759507bf 100644 --- a/src/Discovery/test/Eureka.Test/AppInfo/ApplicationInfoCollectionTest.cs +++ b/src/Discovery/test/Eureka.Test/AppInfo/ApplicationInfoCollectionTest.cs @@ -3,8 +3,11 @@ // See the LICENSE file in the project root for more information. using System.Collections.ObjectModel; +using FluentAssertions.Extensions; using Steeltoe.Discovery.Eureka.AppInfo; +using Steeltoe.Discovery.Eureka.Configuration; using Steeltoe.Discovery.Eureka.Transport; +using Steeltoe.Discovery.Eureka.Util; namespace Steeltoe.Discovery.Eureka.Test.AppInfo; @@ -797,4 +800,127 @@ public void FromJsonApplications_WithMissingInstanceId() app.Name.Should().Be("myApp"); app.Instances.Should().BeEmpty(); } + + [Fact] + public void ToString_ReturnsExpected() + { + var apps = new ApplicationInfoCollection([ + new ApplicationInfo("ServiceA", [ + new InstanceInfo("full-instance-001", "ServiceA", "prod-server-01.example.com", "10.20.30.40", new DataCenterInfo + { + Name = DataCenterName.Amazon + }, TimeProvider.System) + { + AppGroupName = "ServiceGroup", + Status = InstanceStatus.Up, + OverriddenStatus = InstanceStatus.OutOfService, + VipAddress = "service-a-vip", + SecureVipAddress = "service-a-secure-vip", + NonSecurePort = 8080, + IsNonSecurePortEnabled = true, + SecurePort = 8443, + IsSecurePortEnabled = true, + HomePageUrl = "http://prod-server-01.example.com:8080/", + StatusPageUrl = "http://prod-server-01.example.com:8080/actuator/info", + HealthCheckUrl = "http://prod-server-01.example.com:8080/actuator/health", + SecureHealthCheckUrl = "https://prod-server-01.example.com:8443/actuator/health", + LeaseInfo = LeaseInfo.FromJson(new JsonLeaseInfo + { + RenewalIntervalInSeconds = 30, + DurationInSeconds = 90, + RegistrationTimestamp = DateTimeConversions.ToJavaMilliseconds(15.June(2024).At(14, 30, 55, 123).AsUtc()), + LastRenewalTimestamp = DateTimeConversions.ToJavaMilliseconds(18.June(2024).At(23, 1, 27, 789).AsUtc()), + EvictionTimestamp = DateTimeConversions.ToJavaMilliseconds(19.June(2024).At(1, 1, 27, 789).AsUtc()), + ServiceUpTimestamp = DateTimeConversions.ToJavaMilliseconds(15.June(2024).At(14, 31, 2, 456).AsUtc()) + }), + ActionType = ActionType.Added, + Metadata = new Dictionary + { + ["datacenter"] = "us-east-1", + ["availability-zone"] = "us-east-1a", + ["version"] = "3.2.1", + ["environment"] = "production", + ["deployment"] = "blue", + ["team"] = "platform" + } + }, + new InstanceInfo("minimal-instance-001", "ServiceA", "prod-server-02.local", "10.20.30.41", new DataCenterInfo + { + Name = DataCenterName.Netflix + }, TimeProvider.System) + ]), + new ApplicationInfo("EmptyService") + ]); + + apps.ToString().Should().Be(""" + [ + { + "Name": "EmptyService", + "Instances": [] + }, + { + "Name": "ServiceA", + "Instances": [ + { + "InstanceId": "full-instance-001", + "AppName": "ServiceA", + "AppGroupName": "ServiceGroup", + "HostName": "prod-server-01.example.com", + "IPAddress": "10.20.30.40", + "DataCenterInfo": { + "Name": "Amazon" + }, + "VipAddress": "service-a-vip", + "SecureVipAddress": "service-a-secure-vip", + "NonSecurePort": 8080, + "IsNonSecurePortEnabled": true, + "SecurePort": 8443, + "IsSecurePortEnabled": true, + "Status": "Up", + "OverriddenStatus": "OutOfService", + "EffectiveStatus": "OutOfService", + "HomePageUrl": "http://prod-server-01.example.com:8080/", + "StatusPageUrl": "http://prod-server-01.example.com:8080/actuator/info", + "HealthCheckUrl": "http://prod-server-01.example.com:8080/actuator/health", + "SecureHealthCheckUrl": "https://prod-server-01.example.com:8443/actuator/health", + "LeaseInfo": { + "RenewalInterval": "00:00:30", + "Duration": "00:01:30", + "RegistrationTimeUtc": "2024-06-15T14:30:55.123Z", + "LastRenewalTimeUtc": "2024-06-18T23:01:27.789Z", + "EvictionTimeUtc": "2024-06-19T01:01:27.789Z", + "ServiceUpTimeUtc": "2024-06-15T14:31:02.456Z" + }, + "Metadata": { + "datacenter": "us-east-1", + "availability-zone": "us-east-1a", + "version": "3.2.1", + "environment": "production", + "deployment": "blue", + "team": "platform" + }, + "ActionType": "Added", + "IsDirty": false + }, + { + "InstanceId": "minimal-instance-001", + "AppName": "ServiceA", + "HostName": "prod-server-02.local", + "IPAddress": "10.20.30.41", + "DataCenterInfo": { + "Name": "Netflix" + }, + "NonSecurePort": 0, + "IsNonSecurePortEnabled": false, + "SecurePort": 0, + "IsSecurePortEnabled": false, + "EffectiveStatus": "Unknown", + "Metadata": {}, + "IsDirty": false + } + ] + } + ] + """); + } } From 7826e45f01ee2e9f14c6ba0e03fd4deb11004598 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Wed, 4 Mar 2026 11:48:34 +0100 Subject: [PATCH 02/18] Eureka: Remove unused type --- .../Eureka/Transport/JsonApplicationRoot.cs | 13 ---- .../Transport/JsonApplicationRootTest.cs | 72 ------------------- 2 files changed, 85 deletions(-) delete mode 100644 src/Discovery/src/Eureka/Transport/JsonApplicationRoot.cs delete mode 100644 src/Discovery/test/Eureka.Test/Transport/JsonApplicationRootTest.cs diff --git a/src/Discovery/src/Eureka/Transport/JsonApplicationRoot.cs b/src/Discovery/src/Eureka/Transport/JsonApplicationRoot.cs deleted file mode 100644 index 5957fa4a22..0000000000 --- a/src/Discovery/src/Eureka/Transport/JsonApplicationRoot.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information. - -using System.Text.Json.Serialization; - -namespace Steeltoe.Discovery.Eureka.Transport; - -internal sealed class JsonApplicationRoot -{ - [JsonPropertyName("application")] - public JsonApplication? Application { get; set; } -} diff --git a/src/Discovery/test/Eureka.Test/Transport/JsonApplicationRootTest.cs b/src/Discovery/test/Eureka.Test/Transport/JsonApplicationRootTest.cs deleted file mode 100644 index 5df09733ae..0000000000 --- a/src/Discovery/test/Eureka.Test/Transport/JsonApplicationRootTest.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information. - -using System.Text.Json; -using Steeltoe.Discovery.Eureka.Transport; - -namespace Steeltoe.Discovery.Eureka.Test.Transport; - -public sealed class JsonApplicationRootTest -{ - [Fact] - public void Deserialize_GoodJson() - { - const string json = """ - { - "application": { - "name": "FOO", - "instance": [ - { - "instanceId": "localhost:foo", - "hostName": "localhost", - "app": "FOO", - "ipAddr": "192.168.56.1", - "status": "UP", - "overriddenStatus": "UNKNOWN", - "port": { - "$": 8080, - "@enabled": "true" - }, - "securePort": { - "$": 443, - "@enabled": "false" - }, - "countryId": 1, - "dataCenterInfo": { - "@class": "com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo", - "name": "MyOwn" - }, - "leaseInfo": { - "renewalIntervalInSecs": 30, - "durationInSecs": 90, - "registrationTimestamp": 1458152330783, - "lastRenewalTimestamp": 1458243422342, - "evictionTimestamp": 0, - "serviceUpTimestamp": 1458152330783 - }, - "metadata": { - "@class": "java.util.Collections$EmptyMap" - }, - "homePageUrl": "http://localhost:8080/", - "statusPageUrl": "http://localhost:8080/info", - "healthCheckUrl": "http://localhost:8080/health", - "vipAddress": "foo", - "isCoordinatingDiscoveryServer": "false", - "lastUpdatedTimestamp": "1458152330783", - "lastDirtyTimestamp": "1458152330696", - "actionType": "ADDED" - } - ] - } - } - """; - - var result = JsonSerializer.Deserialize(json); - - result.Should().NotBeNull(); - result.Application.Should().NotBeNull(); - result.Application.Name.Should().Be("FOO"); - result.Application.Instances.Should().ContainSingle(); - } -} From e89902d6325a626f99360d95e6efd2908e8aef8a Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:16:37 +0100 Subject: [PATCH 03/18] Eureka: Improve existing serialization tests --- src/Discovery/src/Eureka/EurekaClient.cs | 4 +- .../Eureka.Test/Transport/EurekaClientTest.cs | 8 +- .../Transport/JsonApplicationTest.cs | 70 +++++------- .../Transport/JsonApplicationsRootTest.cs | 58 +--------- .../Transport/JsonApplicationsTest.cs | 79 +++++-------- .../Transport/JsonInstanceInfoRootTest.cs | 76 ++++--------- .../Transport/JsonInstanceInfoTest.cs | 107 +++++++++++++++++- .../Eureka.Test/Transport/JsonLeaseTest.cs | 48 ++++++-- 8 files changed, 229 insertions(+), 221 deletions(-) diff --git a/src/Discovery/src/Eureka/EurekaClient.cs b/src/Discovery/src/Eureka/EurekaClient.cs index 47fa8b7de6..269ada37de 100644 --- a/src/Discovery/src/Eureka/EurekaClient.cs +++ b/src/Discovery/src/Eureka/EurekaClient.cs @@ -37,12 +37,12 @@ public sealed partial class EurekaClient private static readonly Task TaskOfNull = Task.FromResult(null); private static readonly TimeSpan GetAccessTokenTimeout = TimeSpan.FromSeconds(10); - private static readonly JsonSerializerOptions RequestSerializerOptions = new() + internal static readonly JsonSerializerOptions RequestSerializerOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; - private static readonly JsonSerializerOptions ResponseSerializerOptions = new() + internal static readonly JsonSerializerOptions ResponseSerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = diff --git a/src/Discovery/test/Eureka.Test/Transport/EurekaClientTest.cs b/src/Discovery/test/Eureka.Test/Transport/EurekaClientTest.cs index 9e5958cacf..b69254588b 100644 --- a/src/Discovery/test/Eureka.Test/Transport/EurekaClientTest.cs +++ b/src/Discovery/test/Eureka.Test/Transport/EurekaClientTest.cs @@ -4,7 +4,7 @@ using System.Net; using System.Text; -using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using RichardSzalay.MockHttp; @@ -327,7 +327,7 @@ public async Task RegisterAsync_SendsRequestToServer() { using var capturingLoggerProvider = new CapturingLoggerProvider(category => category.StartsWith("Steeltoe.", StringComparison.Ordinal)); - using JsonDocument requestDocument = JsonDocument.Parse(""" + string jsonRequest = JsonNode.Parse(""" { "instance": { "instanceId": "some", @@ -354,9 +354,7 @@ public async Task RegisterAsync_SendsRequestToServer() "lastDirtyTimestamp": "1708427732823" } } - """); - - string jsonRequest = JsonSerializer.Serialize(requestDocument); + """)!.ToJsonString(); var services = new ServiceCollection(); services.AddLogging(options => options.SetMinimumLevel(LogLevel.Trace).AddProvider(capturingLoggerProvider)); diff --git a/src/Discovery/test/Eureka.Test/Transport/JsonApplicationTest.cs b/src/Discovery/test/Eureka.Test/Transport/JsonApplicationTest.cs index 2bebc8ce36..2699330a5f 100644 --- a/src/Discovery/test/Eureka.Test/Transport/JsonApplicationTest.cs +++ b/src/Discovery/test/Eureka.Test/Transport/JsonApplicationTest.cs @@ -10,62 +10,48 @@ namespace Steeltoe.Discovery.Eureka.Test.Transport; public sealed class JsonApplicationTest { [Fact] - public void Deserialize_GoodJson() + public void Deserialize_InstanceArray() { const string json = """ { "name": "FOO", "instance": [ { - "instanceId": "localhost:foo", - "hostName": "localhost", - "app": "FOO", - "ipAddr": "192.168.56.1", - "status": "UP", - "overriddenStatus": "UNKNOWN", - "port": { - "$": 8080, - "@enabled": "true" - }, - "securePort": { - "$": 443, - "@enabled": "false" - }, - "countryId": 1, - "dataCenterInfo": { - "@class": "com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo", - "name": "MyOwn" - }, - "leaseInfo": { - "renewalIntervalInSecs": 30, - "durationInSecs": 90, - "registrationTimestamp": 1457714988223, - "lastRenewalTimestamp": 1457716158319, - "evictionTimestamp": 0, - "serviceUpTimestamp": 1457714988223 - }, - "metadata": { - "@class": "java.util.Collections$EmptyMap" - }, - "homePageUrl": "http://localhost:8080/", - "statusPageUrl": "http://localhost:8080/info", - "healthCheckUrl": "http://localhost:8080/health", - "vipAddress": "foo", - "isCoordinatingDiscoveryServer": "false", - "lastUpdatedTimestamp": "1457714988223", - "lastDirtyTimestamp": "1457714988172", - "actionType": "ADDED" + "instanceId": "localhost:foo" } ] } """; - var result = JsonSerializer.Deserialize(json); + var result = JsonSerializer.Deserialize(json, EurekaClient.RequestSerializerOptions); result.Should().NotBeNull(); result.Name.Should().Be("FOO"); - result.Instances.Should().ContainSingle(); - // Rest is validated by JsonInstanceInfoTest + JsonInstanceInfo? instance = result.Instances.Should().ContainSingle().Subject; + instance.Should().NotBeNull(); + instance.InstanceId.Should().Be("localhost:foo"); + } + + [Fact] + public void Deserialize_InstanceSingleElement() + { + const string json = """ + { + "name": "FOO", + "instance": { + "instanceId": "localhost:foo" + } + } + """; + + var result = JsonSerializer.Deserialize(json, EurekaClient.ResponseSerializerOptions); + + result.Should().NotBeNull(); + result.Name.Should().Be("FOO"); + + JsonInstanceInfo? instance = result.Instances.Should().ContainSingle().Subject; + instance.Should().NotBeNull(); + instance.InstanceId.Should().Be("localhost:foo"); } } diff --git a/src/Discovery/test/Eureka.Test/Transport/JsonApplicationsRootTest.cs b/src/Discovery/test/Eureka.Test/Transport/JsonApplicationsRootTest.cs index fcbd465e8d..392d2d148a 100644 --- a/src/Discovery/test/Eureka.Test/Transport/JsonApplicationsRootTest.cs +++ b/src/Discovery/test/Eureka.Test/Transport/JsonApplicationsRootTest.cs @@ -10,69 +10,17 @@ namespace Steeltoe.Discovery.Eureka.Test.Transport; public sealed class JsonApplicationsRootTest { [Fact] - public void Deserialize_GoodJson() + public void Deserialize() { const string json = """ { - "applications": { - "versions__delta": "1", - "apps__hashcode": "UP_1_", - "application": [ - { - "name": "FOO", - "instance": [ - { - "instanceId": "localhost:foo", - "hostName": "localhost", - "app": "FOO", - "ipAddr": "192.168.56.1", - "status": "UP", - "overriddenStatus": "UNKNOWN", - "port": { - "$": 8080, - "@enabled": "true" - }, - "securePort": { - "$": 443, - "@enabled": "false" - }, - "countryId": 1, - "dataCenterInfo": { - "@class": "com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo", - "name": "MyOwn" - }, - "leaseInfo": { - "renewalIntervalInSecs": 30, - "durationInSecs": 90, - "registrationTimestamp": 1457714988223, - "lastRenewalTimestamp": 1457716158319, - "evictionTimestamp": 0, - "serviceUpTimestamp": 1457714988223 - }, - "metadata": { - "@class": "java.util.Collections$EmptyMap" - }, - "homePageUrl": "http://localhost:8080/", - "statusPageUrl": "http://localhost:8080/info", - "healthCheckUrl": "http://localhost:8080/health", - "vipAddress": "foo", - "isCoordinatingDiscoveryServer": "false", - "lastUpdatedTimestamp": "1457714988223", - "lastDirtyTimestamp": "1457714988172", - "actionType": "ADDED" - } - ] - } - ] - } + "applications": {} } """; - var result = JsonSerializer.Deserialize(json); + var result = JsonSerializer.Deserialize(json, EurekaClient.ResponseSerializerOptions); result.Should().NotBeNull(); result.Applications.Should().NotBeNull(); - - // Rest is validated by JsonApplicationsTest } } diff --git a/src/Discovery/test/Eureka.Test/Transport/JsonApplicationsTest.cs b/src/Discovery/test/Eureka.Test/Transport/JsonApplicationsTest.cs index 60f22290c5..57e9a00b1b 100644 --- a/src/Discovery/test/Eureka.Test/Transport/JsonApplicationsTest.cs +++ b/src/Discovery/test/Eureka.Test/Transport/JsonApplicationsTest.cs @@ -10,7 +10,31 @@ namespace Steeltoe.Discovery.Eureka.Test.Transport; public sealed class JsonApplicationsTest { [Fact] - public void Deserialize_GoodJson() + public void Deserialize_ApplicationSingleElement() + { + const string json = """ + { + "versions__delta": "1", + "apps__hashcode": "UP_1_", + "application": { + "name": "FOO" + } + } + """; + + var result = JsonSerializer.Deserialize(json, EurekaClient.RequestSerializerOptions); + + result.Should().NotBeNull(); + result.VersionDelta.Should().Be(1); + result.AppsHashCode.Should().Be("UP_1_"); + + JsonApplication? app = result.Applications.Should().ContainSingle().Subject; + app.Should().NotBeNull(); + app.Name.Should().Be("FOO"); + } + + [Fact] + public void Deserialize_ApplicationArray() { const string json = """ { @@ -18,61 +42,20 @@ public void Deserialize_GoodJson() "apps__hashcode": "UP_1_", "application": [ { - "name": "FOO", - "instance": [ - { - "instanceId": "localhost:foo", - "hostName": "localhost", - "app": "FOO", - "ipAddr": "192.168.56.1", - "status": "UP", - "overriddenStatus": "UNKNOWN", - "port": { - "$": 8080, - "@enabled": "true" - }, - "securePort": { - "$": 443, - "@enabled": "false" - }, - "countryId": 1, - "dataCenterInfo": { - "@class": "com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo", - "name": "MyOwn" - }, - "leaseInfo": { - "renewalIntervalInSecs": 30, - "durationInSecs": 90, - "registrationTimestamp": 1457714988223, - "lastRenewalTimestamp": 1457716158319, - "evictionTimestamp": 0, - "serviceUpTimestamp": 1457714988223 - }, - "metadata": { - "@class": "java.util.Collections$EmptyMap" - }, - "homePageUrl": "http://localhost:8080/", - "statusPageUrl": "http://localhost:8080/info", - "healthCheckUrl": "http://localhost:8080/health", - "vipAddress": "foo", - "isCoordinatingDiscoveryServer": "false", - "lastUpdatedTimestamp": "1457714988223", - "lastDirtyTimestamp": "1457714988172", - "actionType": "ADDED" - } - ] + "name": "FOO" } ] } """; - var result = JsonSerializer.Deserialize(json); + var result = JsonSerializer.Deserialize(json, EurekaClient.ResponseSerializerOptions); result.Should().NotBeNull(); - result.AppsHashCode.Should().Be("UP_1_"); result.VersionDelta.Should().Be(1); - result.Applications.Should().ContainSingle(); + result.AppsHashCode.Should().Be("UP_1_"); - // Rest is validated by JsonApplicationTest + JsonApplication? app = result.Applications.Should().ContainSingle().Subject; + app.Should().NotBeNull(); + app.Name.Should().Be("FOO"); } } diff --git a/src/Discovery/test/Eureka.Test/Transport/JsonInstanceInfoRootTest.cs b/src/Discovery/test/Eureka.Test/Transport/JsonInstanceInfoRootTest.cs index 6eb99be928..a9680f9955 100644 --- a/src/Discovery/test/Eureka.Test/Transport/JsonInstanceInfoRootTest.cs +++ b/src/Discovery/test/Eureka.Test/Transport/JsonInstanceInfoRootTest.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Text.Json; -using Steeltoe.Discovery.Eureka.AppInfo; +using Steeltoe.Common.TestResources; using Steeltoe.Discovery.Eureka.Transport; namespace Steeltoe.Discovery.Eureka.Test.Transport; @@ -11,68 +11,34 @@ namespace Steeltoe.Discovery.Eureka.Test.Transport; public sealed class JsonInstanceInfoRootTest { [Fact] - public void Deserialize_GoodJson() + public void Serialize() + { + var root = new JsonInstanceInfoRoot + { + Instance = new JsonInstanceInfo() + }; + + string result = JsonSerializer.Serialize(root, EurekaClient.RequestSerializerOptions); + + result.Should().BeJson(""" + { + "instance": {} + } + """); + } + + [Fact] + public void Deserialize() { const string json = """ { - "instance": { - "instanceId": "DESKTOP-GNQ5SUT", - "app": "FOOBAR", - "appGroupName": null, - "ipAddr": "192.168.0.147", - "sid": "na", - "port": { - "@enabled": true, - "$": 80 - }, - "securePort": { - "@enabled": false, - "$": 443 - }, - "homePageUrl": "http://DESKTOP-GNQ5SUT:80/", - "statusPageUrl": "http://DESKTOP-GNQ5SUT:80/Status", - "healthCheckUrl": "http://DESKTOP-GNQ5SUT:80/health-check", - "secureHealthCheckUrl": null, - "vipAddress": "DESKTOP-GNQ5SUT:80", - "secureVipAddress": "DESKTOP-GNQ5SUT:443", - "countryId": 1, - "dataCenterInfo": { - "@class": "com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo", - "name": "MyOwn" - }, - "hostName": "DESKTOP-GNQ5SUT", - "status": "UP", - "overriddenStatus": "UNKNOWN", - "leaseInfo": { - "renewalIntervalInSecs": 30, - "durationInSecs": 90, - "registrationTimestamp": 0, - "lastRenewalTimestamp": 0, - "renewalTimestamp": 0, - "evictionTimestamp": 0, - "serviceUpTimestamp": 0 - }, - "isCoordinatingDiscoveryServer": false, - "metadata": { - "@class": "java.util.Collections$EmptyMap", - "metadata": null - }, - "lastUpdatedTimestamp": 1458116137663, - "lastDirtyTimestamp": 1458116137663, - "actionType": "ADDED", - "asgName": null - } + "instance": {} } """; - var result = JsonSerializer.Deserialize(json); + var result = JsonSerializer.Deserialize(json, EurekaClient.ResponseSerializerOptions); result.Should().NotBeNull(); result.Instance.Should().NotBeNull(); - - // Random check some values - result.Instance.ActionType.Should().Be(ActionType.Added); - result.Instance.HealthCheckUrl.Should().Be("http://DESKTOP-GNQ5SUT:80/health-check"); - result.Instance.AppName.Should().Be("FOOBAR"); } } diff --git a/src/Discovery/test/Eureka.Test/Transport/JsonInstanceInfoTest.cs b/src/Discovery/test/Eureka.Test/Transport/JsonInstanceInfoTest.cs index eebfffe565..edbfd3001c 100644 --- a/src/Discovery/test/Eureka.Test/Transport/JsonInstanceInfoTest.cs +++ b/src/Discovery/test/Eureka.Test/Transport/JsonInstanceInfoTest.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Text.Json; +using Steeltoe.Common.TestResources; using Steeltoe.Discovery.Eureka.AppInfo; using Steeltoe.Discovery.Eureka.Transport; @@ -11,7 +12,105 @@ namespace Steeltoe.Discovery.Eureka.Test.Transport; public sealed class JsonInstanceInfoTest { [Fact] - public void Deserialize_GoodJson() + public void Serialize() + { + var instanceInfo = new JsonInstanceInfo + { + InstanceId = "localhost:foo", + HostName = "localhost", + AppName = "FOO", + IPAddress = "192.168.56.1", + Status = InstanceStatus.Up, + OverriddenStatus = InstanceStatus.OutOfService, + OverriddenStatusLegacy = InstanceStatus.Down, + Port = new JsonPortWrapper + { + Enabled = true, + Port = 8080 + }, + SecurePort = new JsonPortWrapper + { + Enabled = false, + Port = 443 + }, + CountryId = 1, + DataCenterInfo = new JsonDataCenterInfo + { + ClassName = "com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo", + Name = "MyOwn" + }, + LeaseInfo = new JsonLeaseInfo + { + RenewalIntervalInSeconds = 30, + DurationInSeconds = 90, + RegistrationTimestamp = 1_457_714_988_223, + LastRenewalTimestamp = 1_457_716_158_319, + EvictionTimestamp = 1_457_715_134_123, + ServiceUpTimestamp = 1_457_714_988_223 + }, + Metadata = new Dictionary + { + ["@class"] = "java.util.Collections$EmptyMap" + }, + HomePageUrl = "http://localhost:8080/", + StatusPageUrl = "http://localhost:8080/info", + HealthCheckUrl = "http://localhost:8080/health", + VipAddress = "foo", + IsCoordinatingDiscoveryServer = false, + LastUpdatedTimestamp = 1_457_714_988_223, + LastDirtyTimestamp = 1_457_714_988_172, + ActionType = ActionType.Added + }; + + string result = JsonSerializer.Serialize(instanceInfo, EurekaClient.RequestSerializerOptions); + + result.Should().BeJson(""" + { + "instanceId": "localhost:foo", + "app": "FOO", + "ipAddr": "192.168.56.1", + "port": { + "@enabled": "true", + "$": 8080 + }, + "securePort": { + "@enabled": "false", + "$": 443 + }, + "homePageUrl": "http://localhost:8080/", + "statusPageUrl": "http://localhost:8080/info", + "healthCheckUrl": "http://localhost:8080/health", + "vipAddress": "foo", + "countryId": 1, + "dataCenterInfo": { + "@class": "com.netflix.appinfo.InstanceInfo$DefaultDataCenterInfo", + "name": "MyOwn" + }, + "hostName": "localhost", + "status": "UP", + "overriddenStatus": "OUT_OF_SERVICE", + "overriddenstatus": "DOWN", + "leaseInfo": { + "renewalIntervalInSecs": 30, + "durationInSecs": 90, + "registrationTimestamp": "1457714988223", + "lastRenewalTimestamp": "1457716158319", + "evictionTimestamp": "1457715134123", + "serviceUpTimestamp": "1457714988223" + }, + "isCoordinatingDiscoveryServer": "false", + "metadata": { + "@class": "java.util.Collections$EmptyMap" + }, + "lastUpdatedTimestamp": "1457714988223", + "lastDirtyTimestamp": "1457714988172", + "actionType": "ADDED" + } + """); + } + + [Fact] + public void Deserialize() { const string json = """ { @@ -40,7 +139,7 @@ public void Deserialize_GoodJson() "durationInSecs": 90, "registrationTimestamp": 1457714988223, "lastRenewalTimestamp": 1457716158319, - "evictionTimestamp": 0, + "evictionTimestamp": 1457715134123, "serviceUpTimestamp": 1457714988223 }, "metadata": { @@ -57,7 +156,7 @@ public void Deserialize_GoodJson() } """; - var result = JsonSerializer.Deserialize(json); + var result = JsonSerializer.Deserialize(json, EurekaClient.ResponseSerializerOptions); result.Should().NotBeNull(); result.InstanceId.Should().Be("localhost:foo"); @@ -82,7 +181,7 @@ public void Deserialize_GoodJson() result.LeaseInfo.DurationInSeconds.Should().Be(90); result.LeaseInfo.RegistrationTimestamp.Should().Be(1_457_714_988_223); result.LeaseInfo.LastRenewalTimestamp.Should().Be(1_457_716_158_319); - result.LeaseInfo.EvictionTimestamp.Should().Be(0); + result.LeaseInfo.EvictionTimestamp.Should().Be(1_457_715_134_123); result.LeaseInfo.ServiceUpTimestamp.Should().Be(1_457_714_988_223); result.Metadata.Should().ContainSingle(); result.Metadata.Should().ContainKey("@class").WhoseValue.Should().Be("java.util.Collections$EmptyMap"); diff --git a/src/Discovery/test/Eureka.Test/Transport/JsonLeaseTest.cs b/src/Discovery/test/Eureka.Test/Transport/JsonLeaseTest.cs index c8366ad635..6cf1b24ff8 100644 --- a/src/Discovery/test/Eureka.Test/Transport/JsonLeaseTest.cs +++ b/src/Discovery/test/Eureka.Test/Transport/JsonLeaseTest.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information. using System.Text.Json; +using Steeltoe.Common.TestResources; using Steeltoe.Discovery.Eureka.Transport; namespace Steeltoe.Discovery.Eureka.Test.Transport; @@ -10,7 +11,34 @@ namespace Steeltoe.Discovery.Eureka.Test.Transport; public sealed class JsonLeaseTest { [Fact] - public void Deserialize_GoodJson() + public void Serialize() + { + var leaseInfo = new JsonLeaseInfo + { + RenewalIntervalInSeconds = 30, + DurationInSeconds = 90, + RegistrationTimestamp = 1_457_714_988_223, + LastRenewalTimestamp = 1_457_716_158_319, + EvictionTimestamp = 1_457_715_134_123, + ServiceUpTimestamp = 1_457_714_988_223 + }; + + string result = JsonSerializer.Serialize(leaseInfo, EurekaClient.RequestSerializerOptions); + + result.Should().BeJson(""" + { + "renewalIntervalInSecs": 30, + "durationInSecs": 90, + "registrationTimestamp": "1457714988223", + "lastRenewalTimestamp": "1457716158319", + "evictionTimestamp": "1457715134123", + "serviceUpTimestamp": "1457714988223" + } + """); + } + + [Fact] + public void Deserialize() { const string json = """ { @@ -18,19 +46,19 @@ public void Deserialize_GoodJson() "durationInSecs": 90, "registrationTimestamp": 1457714988223, "lastRenewalTimestamp": 1457716158319, - "evictionTimestamp": 0, + "evictionTimestamp": 1457715134123, "serviceUpTimestamp": 1457714988223 } """; - var leaseInfo = JsonSerializer.Deserialize(json); + var result = JsonSerializer.Deserialize(json, EurekaClient.ResponseSerializerOptions); - leaseInfo.Should().NotBeNull(); - leaseInfo.RenewalIntervalInSeconds.Should().Be(30); - leaseInfo.DurationInSeconds.Should().Be(90); - leaseInfo.RegistrationTimestamp.Should().Be(1_457_714_988_223); - leaseInfo.LastRenewalTimestamp.Should().Be(1_457_716_158_319); - leaseInfo.EvictionTimestamp.Should().Be(0); - leaseInfo.ServiceUpTimestamp.Should().Be(1_457_714_988_223); + result.Should().NotBeNull(); + result.RenewalIntervalInSeconds.Should().Be(30); + result.DurationInSeconds.Should().Be(90); + result.RegistrationTimestamp.Should().Be(1_457_714_988_223); + result.LastRenewalTimestamp.Should().Be(1_457_716_158_319); + result.EvictionTimestamp.Should().Be(1_457_715_134_123); + result.ServiceUpTimestamp.Should().Be(1_457_714_988_223); } } From 47bc23a81d7f02a957252a118b81386bf1d07481 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:35:10 +0100 Subject: [PATCH 04/18] Eureka: Use JSON source generator --- src/Discovery/src/Eureka/EurekaClient.cs | 20 ++----------------- .../Transport/EurekaJsonSerializerContext.cs | 14 +++++++++++++ .../Transport/JsonApplicationConverter.cs | 4 ++-- .../Transport/JsonInstanceInfoConverter.cs | 4 ++-- .../Transport/JsonApplicationTest.cs | 4 ++-- .../Transport/JsonApplicationsRootTest.cs | 2 +- .../Transport/JsonApplicationsTest.cs | 4 ++-- .../Transport/JsonInstanceInfoRootTest.cs | 4 ++-- .../Transport/JsonInstanceInfoTest.cs | 4 ++-- .../Eureka.Test/Transport/JsonLeaseTest.cs | 4 ++-- .../Transport/JsonSerializationTest.cs | 2 +- 11 files changed, 32 insertions(+), 34 deletions(-) create mode 100644 src/Discovery/src/Eureka/Transport/EurekaJsonSerializerContext.cs diff --git a/src/Discovery/src/Eureka/EurekaClient.cs b/src/Discovery/src/Eureka/EurekaClient.cs index 269ada37de..ad4b97e420 100644 --- a/src/Discovery/src/Eureka/EurekaClient.cs +++ b/src/Discovery/src/Eureka/EurekaClient.cs @@ -8,7 +8,6 @@ using System.Net.Http.Json; using System.Text; using System.Text.Json; -using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -37,21 +36,6 @@ public sealed partial class EurekaClient private static readonly Task TaskOfNull = Task.FromResult(null); private static readonly TimeSpan GetAccessTokenTimeout = TimeSpan.FromSeconds(10); - internal static readonly JsonSerializerOptions RequestSerializerOptions = new() - { - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - internal static readonly JsonSerializerOptions ResponseSerializerOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Converters = - { - new JsonApplicationConverter(), - new JsonInstanceInfoConverter() - } - }; - private readonly IHttpClientFactory _httpClientFactory; private readonly IOptionsMonitor _optionsMonitor; private readonly EurekaServiceUriStateManager _eurekaServiceUriStateManager; @@ -98,7 +82,7 @@ public async Task RegisterAsync(InstanceInfo instance, CancellationToken cancell string requestBody = JsonSerializer.Serialize(new JsonInstanceInfoRoot { Instance = instance.ToJson() - }, RequestSerializerOptions); + }, EurekaJsonSerializerContext.Default.JsonInstanceInfoRoot); string path = $"apps/{WebUtility.UrlEncode(instance.AppName)}"; await ExecuteRequestAsync(HttpMethod.Post, path, null, requestBody, cancellationToken); @@ -224,7 +208,7 @@ private async Task GetApplicationsAtPathAsync(string { return await ExecuteRequestAsync(HttpMethod.Get, path, null, null, async response => { - var root = await response.Content.ReadFromJsonAsync(ResponseSerializerOptions, cancellationToken); + JsonApplicationsRoot? root = await response.Content.ReadFromJsonAsync(EurekaJsonSerializerContext.Default.JsonApplicationsRoot, cancellationToken); return ApplicationInfoCollection.FromJson(root?.Applications, _timeProvider); }, cancellationToken); } diff --git a/src/Discovery/src/Eureka/Transport/EurekaJsonSerializerContext.cs b/src/Discovery/src/Eureka/Transport/EurekaJsonSerializerContext.cs new file mode 100644 index 0000000000..861981f501 --- /dev/null +++ b/src/Discovery/src/Eureka/Transport/EurekaJsonSerializerContext.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Steeltoe.Discovery.Eureka.Transport; + +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(JsonApplicationsRoot))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(JsonInstanceInfoRoot))] +[JsonSerializable(typeof(List))] +internal sealed partial class EurekaJsonSerializerContext : JsonSerializerContext; diff --git a/src/Discovery/src/Eureka/Transport/JsonApplicationConverter.cs b/src/Discovery/src/Eureka/Transport/JsonApplicationConverter.cs index 906aaa80f1..7129f7d517 100644 --- a/src/Discovery/src/Eureka/Transport/JsonApplicationConverter.cs +++ b/src/Discovery/src/Eureka/Transport/JsonApplicationConverter.cs @@ -13,10 +13,10 @@ internal sealed class JsonApplicationConverter : JsonConverter>(ref reader, options)!; + return JsonSerializer.Deserialize(ref reader, EurekaJsonSerializerContext.Default.ListJsonApplication)!; } - var application = JsonSerializer.Deserialize(ref reader, options); + JsonApplication? application = JsonSerializer.Deserialize(ref reader, EurekaJsonSerializerContext.Default.JsonApplication); return application != null ? [application] : []; } diff --git a/src/Discovery/src/Eureka/Transport/JsonInstanceInfoConverter.cs b/src/Discovery/src/Eureka/Transport/JsonInstanceInfoConverter.cs index 2b751a374c..56f2f21ed7 100644 --- a/src/Discovery/src/Eureka/Transport/JsonInstanceInfoConverter.cs +++ b/src/Discovery/src/Eureka/Transport/JsonInstanceInfoConverter.cs @@ -13,10 +13,10 @@ internal sealed class JsonInstanceInfoConverter : JsonConverter>(ref reader, options)!; + return JsonSerializer.Deserialize(ref reader, EurekaJsonSerializerContext.Default.ListJsonInstanceInfo)!; } - var instanceInfo = JsonSerializer.Deserialize(ref reader, options); + JsonInstanceInfo? instanceInfo = JsonSerializer.Deserialize(ref reader, EurekaJsonSerializerContext.Default.JsonInstanceInfo); return instanceInfo != null ? [instanceInfo] : []; } diff --git a/src/Discovery/test/Eureka.Test/Transport/JsonApplicationTest.cs b/src/Discovery/test/Eureka.Test/Transport/JsonApplicationTest.cs index 2699330a5f..e528bb2e64 100644 --- a/src/Discovery/test/Eureka.Test/Transport/JsonApplicationTest.cs +++ b/src/Discovery/test/Eureka.Test/Transport/JsonApplicationTest.cs @@ -23,7 +23,7 @@ public void Deserialize_InstanceArray() } """; - var result = JsonSerializer.Deserialize(json, EurekaClient.RequestSerializerOptions); + JsonApplication? result = JsonSerializer.Deserialize(json, EurekaJsonSerializerContext.Default.JsonApplication); result.Should().NotBeNull(); result.Name.Should().Be("FOO"); @@ -45,7 +45,7 @@ public void Deserialize_InstanceSingleElement() } """; - var result = JsonSerializer.Deserialize(json, EurekaClient.ResponseSerializerOptions); + JsonApplication? result = JsonSerializer.Deserialize(json, EurekaJsonSerializerContext.Default.JsonApplication); result.Should().NotBeNull(); result.Name.Should().Be("FOO"); diff --git a/src/Discovery/test/Eureka.Test/Transport/JsonApplicationsRootTest.cs b/src/Discovery/test/Eureka.Test/Transport/JsonApplicationsRootTest.cs index 392d2d148a..5e3c9a94b1 100644 --- a/src/Discovery/test/Eureka.Test/Transport/JsonApplicationsRootTest.cs +++ b/src/Discovery/test/Eureka.Test/Transport/JsonApplicationsRootTest.cs @@ -18,7 +18,7 @@ public void Deserialize() } """; - var result = JsonSerializer.Deserialize(json, EurekaClient.ResponseSerializerOptions); + JsonApplicationsRoot? result = JsonSerializer.Deserialize(json, EurekaJsonSerializerContext.Default.JsonApplicationsRoot); result.Should().NotBeNull(); result.Applications.Should().NotBeNull(); diff --git a/src/Discovery/test/Eureka.Test/Transport/JsonApplicationsTest.cs b/src/Discovery/test/Eureka.Test/Transport/JsonApplicationsTest.cs index 57e9a00b1b..84696bdf91 100644 --- a/src/Discovery/test/Eureka.Test/Transport/JsonApplicationsTest.cs +++ b/src/Discovery/test/Eureka.Test/Transport/JsonApplicationsTest.cs @@ -22,7 +22,7 @@ public void Deserialize_ApplicationSingleElement() } """; - var result = JsonSerializer.Deserialize(json, EurekaClient.RequestSerializerOptions); + JsonApplications? result = JsonSerializer.Deserialize(json, EurekaJsonSerializerContext.Default.JsonApplications); result.Should().NotBeNull(); result.VersionDelta.Should().Be(1); @@ -48,7 +48,7 @@ public void Deserialize_ApplicationArray() } """; - var result = JsonSerializer.Deserialize(json, EurekaClient.ResponseSerializerOptions); + JsonApplications? result = JsonSerializer.Deserialize(json, EurekaJsonSerializerContext.Default.JsonApplications); result.Should().NotBeNull(); result.VersionDelta.Should().Be(1); diff --git a/src/Discovery/test/Eureka.Test/Transport/JsonInstanceInfoRootTest.cs b/src/Discovery/test/Eureka.Test/Transport/JsonInstanceInfoRootTest.cs index a9680f9955..83903f2149 100644 --- a/src/Discovery/test/Eureka.Test/Transport/JsonInstanceInfoRootTest.cs +++ b/src/Discovery/test/Eureka.Test/Transport/JsonInstanceInfoRootTest.cs @@ -18,7 +18,7 @@ public void Serialize() Instance = new JsonInstanceInfo() }; - string result = JsonSerializer.Serialize(root, EurekaClient.RequestSerializerOptions); + string result = JsonSerializer.Serialize(root, EurekaJsonSerializerContext.Default.JsonInstanceInfoRoot); result.Should().BeJson(""" { @@ -36,7 +36,7 @@ public void Deserialize() } """; - var result = JsonSerializer.Deserialize(json, EurekaClient.ResponseSerializerOptions); + JsonInstanceInfoRoot? result = JsonSerializer.Deserialize(json, EurekaJsonSerializerContext.Default.JsonInstanceInfoRoot); result.Should().NotBeNull(); result.Instance.Should().NotBeNull(); diff --git a/src/Discovery/test/Eureka.Test/Transport/JsonInstanceInfoTest.cs b/src/Discovery/test/Eureka.Test/Transport/JsonInstanceInfoTest.cs index edbfd3001c..7c842dae07 100644 --- a/src/Discovery/test/Eureka.Test/Transport/JsonInstanceInfoTest.cs +++ b/src/Discovery/test/Eureka.Test/Transport/JsonInstanceInfoTest.cs @@ -62,7 +62,7 @@ public void Serialize() ActionType = ActionType.Added }; - string result = JsonSerializer.Serialize(instanceInfo, EurekaClient.RequestSerializerOptions); + string result = JsonSerializer.Serialize(instanceInfo, EurekaJsonSerializerContext.Default.JsonInstanceInfo); result.Should().BeJson(""" { @@ -156,7 +156,7 @@ public void Deserialize() } """; - var result = JsonSerializer.Deserialize(json, EurekaClient.ResponseSerializerOptions); + JsonInstanceInfo? result = JsonSerializer.Deserialize(json, EurekaJsonSerializerContext.Default.JsonInstanceInfo); result.Should().NotBeNull(); result.InstanceId.Should().Be("localhost:foo"); diff --git a/src/Discovery/test/Eureka.Test/Transport/JsonLeaseTest.cs b/src/Discovery/test/Eureka.Test/Transport/JsonLeaseTest.cs index 6cf1b24ff8..689312b069 100644 --- a/src/Discovery/test/Eureka.Test/Transport/JsonLeaseTest.cs +++ b/src/Discovery/test/Eureka.Test/Transport/JsonLeaseTest.cs @@ -23,7 +23,7 @@ public void Serialize() ServiceUpTimestamp = 1_457_714_988_223 }; - string result = JsonSerializer.Serialize(leaseInfo, EurekaClient.RequestSerializerOptions); + string result = JsonSerializer.Serialize(leaseInfo, EurekaJsonSerializerContext.Default.JsonLeaseInfo); result.Should().BeJson(""" { @@ -51,7 +51,7 @@ public void Deserialize() } """; - var result = JsonSerializer.Deserialize(json, EurekaClient.ResponseSerializerOptions); + JsonLeaseInfo? result = JsonSerializer.Deserialize(json, EurekaJsonSerializerContext.Default.JsonLeaseInfo); result.Should().NotBeNull(); result.RenewalIntervalInSeconds.Should().Be(30); diff --git a/src/Discovery/test/Eureka.Test/Transport/JsonSerializationTest.cs b/src/Discovery/test/Eureka.Test/Transport/JsonSerializationTest.cs index 34bc67c396..ae7d02c9b8 100644 --- a/src/Discovery/test/Eureka.Test/Transport/JsonSerializationTest.cs +++ b/src/Discovery/test/Eureka.Test/Transport/JsonSerializationTest.cs @@ -22,7 +22,7 @@ public void Deserialize_BadJson_Throws() """; #pragma warning restore JSON001 // Invalid JSON pattern - Action action = () => JsonSerializer.Deserialize(json); + Action action = () => JsonSerializer.Deserialize(json, EurekaJsonSerializerContext.Default.JsonInstanceInfo); action.Should().ThrowExactly(); } From 2fdbda0f09fbf91603f37cd6a316fa95bc33498c Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:44:39 +0100 Subject: [PATCH 05/18] Eureka: Use source generator for ToString debug methods --- .../src/Eureka/AppInfo/ApplicationInfo.cs | 2 +- .../AppInfo/ApplicationInfoCollection.cs | 2 +- .../src/Eureka/AppInfo/DataCenterName.cs | 3 +++ .../src/Eureka/AppInfo/InstanceInfo.cs | 2 +- src/Discovery/src/Eureka/AppInfo/LeaseInfo.cs | 2 +- .../Transport/DebugJsonSerializerContext.cs | 12 ++++++++++ .../Transport/DebugSerializerOptions.cs | 24 ------------------- .../AppInfo/ApplicationInfoCollectionTest.cs | 10 ++++---- 8 files changed, 24 insertions(+), 33 deletions(-) create mode 100644 src/Discovery/src/Eureka/Transport/DebugJsonSerializerContext.cs delete mode 100644 src/Discovery/src/Eureka/Transport/DebugSerializerOptions.cs diff --git a/src/Discovery/src/Eureka/AppInfo/ApplicationInfo.cs b/src/Discovery/src/Eureka/AppInfo/ApplicationInfo.cs index df11be4347..257a55202d 100644 --- a/src/Discovery/src/Eureka/AppInfo/ApplicationInfo.cs +++ b/src/Discovery/src/Eureka/AppInfo/ApplicationInfo.cs @@ -53,7 +53,7 @@ private List GetInstancesSnapshot() /// public override string ToString() { - return JsonSerializer.Serialize(this, DebugSerializerOptions.Instance); + return JsonSerializer.Serialize(this, DebugJsonSerializerContext.Default.ApplicationInfo); } internal void Add(InstanceInfo instance) diff --git a/src/Discovery/src/Eureka/AppInfo/ApplicationInfoCollection.cs b/src/Discovery/src/Eureka/AppInfo/ApplicationInfoCollection.cs index 28e0fdbae6..e42f48370e 100644 --- a/src/Discovery/src/Eureka/AppInfo/ApplicationInfoCollection.cs +++ b/src/Discovery/src/Eureka/AppInfo/ApplicationInfoCollection.cs @@ -73,7 +73,7 @@ internal ReadOnlyCollection GetInstancesByVipAddress(string vipAdd /// public override string ToString() { - return JsonSerializer.Serialize(this, DebugSerializerOptions.Instance); + return JsonSerializer.Serialize(this, DebugJsonSerializerContext.Default.ApplicationInfoCollection); } IEnumerator IEnumerable.GetEnumerator() diff --git a/src/Discovery/src/Eureka/AppInfo/DataCenterName.cs b/src/Discovery/src/Eureka/AppInfo/DataCenterName.cs index 5cf05013d3..ede53c628b 100644 --- a/src/Discovery/src/Eureka/AppInfo/DataCenterName.cs +++ b/src/Discovery/src/Eureka/AppInfo/DataCenterName.cs @@ -6,8 +6,11 @@ #pragma warning disable SA1602 // Enumeration items should be documented #endif +using System.Text.Json.Serialization; + namespace Steeltoe.Discovery.Eureka.AppInfo; +[JsonConverter(typeof(JsonStringEnumConverter))] public enum DataCenterName { Netflix, diff --git a/src/Discovery/src/Eureka/AppInfo/InstanceInfo.cs b/src/Discovery/src/Eureka/AppInfo/InstanceInfo.cs index d89d9e6aae..0015f8078e 100644 --- a/src/Discovery/src/Eureka/AppInfo/InstanceInfo.cs +++ b/src/Discovery/src/Eureka/AppInfo/InstanceInfo.cs @@ -268,7 +268,7 @@ public override int GetHashCode() /// public override string ToString() { - return JsonSerializer.Serialize(this, DebugSerializerOptions.Instance); + return JsonSerializer.Serialize(this, DebugJsonSerializerContext.Default.InstanceInfo); } internal static InstanceInfo FromConfiguration(EurekaInstanceOptions options, TimeProvider timeProvider) diff --git a/src/Discovery/src/Eureka/AppInfo/LeaseInfo.cs b/src/Discovery/src/Eureka/AppInfo/LeaseInfo.cs index ecab87747e..2a1d76b2f1 100644 --- a/src/Discovery/src/Eureka/AppInfo/LeaseInfo.cs +++ b/src/Discovery/src/Eureka/AppInfo/LeaseInfo.cs @@ -48,7 +48,7 @@ private LeaseInfo() /// public override string ToString() { - return JsonSerializer.Serialize(this, DebugSerializerOptions.Instance); + return JsonSerializer.Serialize(this, DebugJsonSerializerContext.Default.LeaseInfo); } internal static LeaseInfo? FromJson(JsonLeaseInfo? jsonLeaseInfo) diff --git a/src/Discovery/src/Eureka/Transport/DebugJsonSerializerContext.cs b/src/Discovery/src/Eureka/Transport/DebugJsonSerializerContext.cs new file mode 100644 index 0000000000..07fabaacc2 --- /dev/null +++ b/src/Discovery/src/Eureka/Transport/DebugJsonSerializerContext.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; +using Steeltoe.Discovery.Eureka.AppInfo; + +namespace Steeltoe.Discovery.Eureka.Transport; + +[JsonSourceGenerationOptions(WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(ApplicationInfoCollection))] +internal sealed partial class DebugJsonSerializerContext : JsonSerializerContext; diff --git a/src/Discovery/src/Eureka/Transport/DebugSerializerOptions.cs b/src/Discovery/src/Eureka/Transport/DebugSerializerOptions.cs deleted file mode 100644 index 4aed5c3ec7..0000000000 --- a/src/Discovery/src/Eureka/Transport/DebugSerializerOptions.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information. - -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Steeltoe.Discovery.Eureka.Transport; - -internal static class DebugSerializerOptions -{ - public static JsonSerializerOptions Instance { get; } = new() - { - WriteIndented = true, - Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - ReferenceHandler = ReferenceHandler.IgnoreCycles, - Converters = - { - new JsonStringEnumConverter() - } - }; -} diff --git a/src/Discovery/test/Eureka.Test/AppInfo/ApplicationInfoCollectionTest.cs b/src/Discovery/test/Eureka.Test/AppInfo/ApplicationInfoCollectionTest.cs index c1759507bf..5dca00ae23 100644 --- a/src/Discovery/test/Eureka.Test/AppInfo/ApplicationInfoCollectionTest.cs +++ b/src/Discovery/test/Eureka.Test/AppInfo/ApplicationInfoCollectionTest.cs @@ -876,9 +876,9 @@ public void ToString_ReturnsExpected() "IsNonSecurePortEnabled": true, "SecurePort": 8443, "IsSecurePortEnabled": true, - "Status": "Up", - "OverriddenStatus": "OutOfService", - "EffectiveStatus": "OutOfService", + "Status": "UP", + "OverriddenStatus": "OUT_OF_SERVICE", + "EffectiveStatus": "OUT_OF_SERVICE", "HomePageUrl": "http://prod-server-01.example.com:8080/", "StatusPageUrl": "http://prod-server-01.example.com:8080/actuator/info", "HealthCheckUrl": "http://prod-server-01.example.com:8080/actuator/health", @@ -899,7 +899,7 @@ public void ToString_ReturnsExpected() "deployment": "blue", "team": "platform" }, - "ActionType": "Added", + "ActionType": "ADDED", "IsDirty": false }, { @@ -914,7 +914,7 @@ public void ToString_ReturnsExpected() "IsNonSecurePortEnabled": false, "SecurePort": 0, "IsSecurePortEnabled": false, - "EffectiveStatus": "Unknown", + "EffectiveStatus": "UNKNOWN", "Metadata": {}, "IsDirty": false } From 76d0ac69e46a7ae2268fac530a00295939f08ff8 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:46:07 +0100 Subject: [PATCH 06/18] HttpClients: Use JSON source generator --- .../ServiceInstancesJsonSerializerContext.cs | 11 +++++++++++ .../LoadBalancers/ServiceInstancesResolver.cs | 9 +++++---- 2 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 src/Discovery/src/HttpClients/LoadBalancers/ServiceInstancesJsonSerializerContext.cs diff --git a/src/Discovery/src/HttpClients/LoadBalancers/ServiceInstancesJsonSerializerContext.cs b/src/Discovery/src/HttpClients/LoadBalancers/ServiceInstancesJsonSerializerContext.cs new file mode 100644 index 0000000000..3193aebbc5 --- /dev/null +++ b/src/Discovery/src/HttpClients/LoadBalancers/ServiceInstancesJsonSerializerContext.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Steeltoe.Discovery.HttpClients.LoadBalancers; + +[JsonSourceGenerationOptions] +[JsonSerializable(typeof(ServiceInstancesResolver.JsonSerializableServiceInstance[]))] +internal sealed partial class ServiceInstancesJsonSerializerContext : JsonSerializerContext; diff --git a/src/Discovery/src/HttpClients/LoadBalancers/ServiceInstancesResolver.cs b/src/Discovery/src/HttpClients/LoadBalancers/ServiceInstancesResolver.cs index 6a13c24690..e1231d171d 100644 --- a/src/Discovery/src/HttpClients/LoadBalancers/ServiceInstancesResolver.cs +++ b/src/Discovery/src/HttpClients/LoadBalancers/ServiceInstancesResolver.cs @@ -116,7 +116,8 @@ public async Task> ResolveInstancesAsync(string serviceI { if (cacheValue is { Length: > 0 }) { - var serializableInstances = JsonSerializer.Deserialize>(cacheValue); + JsonSerializableServiceInstance[]? serializableInstances = + JsonSerializer.Deserialize(cacheValue, ServiceInstancesJsonSerializerContext.Default.JsonSerializableServiceInstanceArray); if (serializableInstances != null) { @@ -127,10 +128,10 @@ public async Task> ResolveInstancesAsync(string serviceI return null; } - private static byte[] ToCacheValue(IEnumerable instances) + private static byte[] ToCacheValue(List instances) { JsonSerializableServiceInstance[] serializableInstances = instances.Select(JsonSerializableServiceInstance.CopyFrom).ToArray(); - return JsonSerializer.SerializeToUtf8Bytes(serializableInstances); + return JsonSerializer.SerializeToUtf8Bytes(serializableInstances, ServiceInstancesJsonSerializerContext.Default.JsonSerializableServiceInstanceArray); } [LoggerMessage(Level = LogLevel.Warning, Message = "No discovery clients are registered.")] @@ -142,7 +143,7 @@ private static byte[] ToCacheValue(IEnumerable instances) [LoggerMessage(Level = LogLevel.Error, Message = "Failed to get instances from {DiscoveryClient}.")] private partial void LogFailedToGetInstances(Exception exception, Type discoveryClient); - private sealed class JsonSerializableServiceInstance : IServiceInstance + internal sealed class JsonSerializableServiceInstance : IServiceInstance { // Trust that deserialized instances meet the IServiceInstance contract, so suppress nullability warnings. From 981dc004058960b33d992d36fb7ecf403dc6318f Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Wed, 4 Mar 2026 19:36:58 +0100 Subject: [PATCH 07/18] Replace JsonSerializer usage from certificate tests with simple string replacement --- .../ConfigureCertificateOptionsTest.cs | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/Common/test/Certificates.Test/ConfigureCertificateOptionsTest.cs b/src/Common/test/Certificates.Test/ConfigureCertificateOptionsTest.cs index a2a64797d2..2a93ec848b 100644 --- a/src/Common/test/Certificates.Test/ConfigureCertificateOptionsTest.cs +++ b/src/Common/test/Certificates.Test/ConfigureCertificateOptionsTest.cs @@ -5,7 +5,6 @@ using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; -using System.Text.Json; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -212,19 +211,25 @@ public async Task CertificateOptions_update_on_changed_path(string certificateNa private static string BuildAppSettingsJson(string certificateName, string certificatePath, string keyPath) { - string certificateBlock = $""" - "CertificateFilePath": {JsonSerializer.Serialize(certificatePath)}, - "PrivateKeyFilePath": {JsonSerializer.Serialize(keyPath)} - """; - - string namedCertificateSection = string.IsNullOrEmpty(certificateName) - ? certificateBlock - : $"{JsonSerializer.Serialize(certificateName)}: {{ {certificateBlock} }}"; + string escapedCertificatePath = certificatePath.Replace(@"\", @"\\", StringComparison.Ordinal); + string escapedKeyPath = keyPath.Replace(@"\", @"\\", StringComparison.Ordinal); - return $$""" + return string.IsNullOrEmpty(certificateName) + ? $$""" + { + "Certificates": { + "CertificateFilePath": "{{escapedCertificatePath}}", + "PrivateKeyFilePath": "{{escapedKeyPath}}" + } + } + """ + : $$""" { "Certificates": { - {{namedCertificateSection}} + "{{certificateName}}": { + "CertificateFilePath": "{{escapedCertificatePath}}", + "PrivateKeyFilePath": "{{escapedKeyPath}}" + } } } """; From ad5898f9f757d625e88509306774a1c539788390 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:36:45 +0100 Subject: [PATCH 08/18] SpringBootAdmin client: Use JSON source generator --- .../SpringBootAdminApiClient.cs | 8 +++-- .../SpringBootAdminJsonSerializerContext.cs | 29 +++++++++++++++++++ .../SpringBootAdminClient/HostBuilderTest.cs | 4 +-- 3 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 src/Management/src/Endpoint/SpringBootAdminClient/SpringBootAdminJsonSerializerContext.cs diff --git a/src/Management/src/Endpoint/SpringBootAdminClient/SpringBootAdminApiClient.cs b/src/Management/src/Endpoint/SpringBootAdminClient/SpringBootAdminApiClient.cs index 99ca446b9b..4ebefa084b 100644 --- a/src/Management/src/Endpoint/SpringBootAdminClient/SpringBootAdminApiClient.cs +++ b/src/Management/src/Endpoint/SpringBootAdminClient/SpringBootAdminApiClient.cs @@ -28,7 +28,9 @@ public SpringBootAdminApiClient(IHttpClientFactory httpClientFactory) using HttpClient httpClient = CreateHttpClient(options.ConnectionTimeout); string requestUri = $"{options.Url}/instances"; - HttpResponseMessage response = await httpClient.PostAsJsonAsync(requestUri, application, cancellationToken); + + HttpResponseMessage response = await httpClient.PostAsJsonAsync(requestUri, application, + SpringBootAdminJsonSerializerContext.OptionsWithReflectionFallback, cancellationToken); if (!response.IsSuccessStatusCode) { @@ -36,7 +38,9 @@ public SpringBootAdminApiClient(IHttpClientFactory httpClientFactory) throw new HttpRequestException($"Error response from HTTP POST request at {requestUri}: {errorResponse}"); } - var registrationResult = await response.Content.ReadFromJsonAsync(cancellationToken); + RegistrationResult? registrationResult = + await response.Content.ReadFromJsonAsync(SpringBootAdminJsonSerializerContext.Default.RegistrationResult, cancellationToken); + return registrationResult?.Id; } diff --git a/src/Management/src/Endpoint/SpringBootAdminClient/SpringBootAdminJsonSerializerContext.cs b/src/Management/src/Endpoint/SpringBootAdminClient/SpringBootAdminJsonSerializerContext.cs new file mode 100644 index 0000000000..5e862565b5 --- /dev/null +++ b/src/Management/src/Endpoint/SpringBootAdminClient/SpringBootAdminJsonSerializerContext.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Steeltoe.Management.Endpoint.SpringBootAdminClient; + +[JsonSourceGenerationOptions] +[JsonSerializable(typeof(Application))] +[JsonSerializable(typeof(SpringBootAdminApiClient.RegistrationResult))] +internal sealed partial class SpringBootAdminJsonSerializerContext : JsonSerializerContext +{ + private static readonly Lazy LazyOptionsWithReflectionFallback = new(CreateOptionsWithReflectionFallback); + + // Application.Metadata is IDictionary, whose values can be arbitrary types (e.g. DateTime). + // The source generator can't know these types at compile time, so we fall back to reflection for them. + internal static JsonSerializerOptions OptionsWithReflectionFallback => LazyOptionsWithReflectionFallback.Value; + + private static JsonSerializerOptions CreateOptionsWithReflectionFallback() + { + var options = new JsonSerializerOptions(); + options.TypeInfoResolverChain.Add(Default); + options.TypeInfoResolverChain.Add(new DefaultJsonTypeInfoResolver()); + return options; + } +} diff --git a/src/Management/test/Endpoint.Test/SpringBootAdminClient/HostBuilderTest.cs b/src/Management/test/Endpoint.Test/SpringBootAdminClient/HostBuilderTest.cs index 7687a578c4..30716b1d34 100644 --- a/src/Management/test/Endpoint.Test/SpringBootAdminClient/HostBuilderTest.cs +++ b/src/Management/test/Endpoint.Test/SpringBootAdminClient/HostBuilderTest.cs @@ -43,7 +43,7 @@ public async Task CanUseDynamicHttpPort() handler.Mock.Expect(HttpMethod.Post, "http://sba-server.com/instances").With(message => { - requestApplication = message.Content?.ReadFromJsonAsync().GetAwaiter().GetResult(); + requestApplication = message.Content?.ReadFromJsonAsync(SpringBootAdminJsonSerializerContext.Default.Application).GetAwaiter().GetResult(); return true; }).Respond("application/json", """{"Id":"1"}"""); @@ -79,7 +79,7 @@ public async Task CanUseDynamicHttpsPort() handler.Mock.Expect(HttpMethod.Post, "http://sba-server.com/instances").With(message => { - requestApplication = message.Content?.ReadFromJsonAsync().GetAwaiter().GetResult(); + requestApplication = message.Content?.ReadFromJsonAsync(SpringBootAdminJsonSerializerContext.Default.Application).GetAwaiter().GetResult(); return true; }).Respond("application/json", """{"Id":"1"}"""); From e2e21c42ed3401d4a828d42e2060681506b5b0c5 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:17:58 +0100 Subject: [PATCH 09/18] Cloud foundry middleware: gracefully handle failure to parse permissions response; don't assume that read_basic_data is always true --- .../Configuration/EndpointPermissions.cs | 2 +- .../CloudFoundry/PermissionsProvider.cs | 47 +++++++++++-------- .../CloudControllerPermissionsMock.cs | 12 +++-- .../CloudFoundry/CloudFoundryActuatorTest.cs | 2 +- .../CloudFoundrySecurityMiddlewareTest.cs | 24 +++++----- ...dFoundrySecurityMiddlewareTestScenarios.cs | 21 +++++++-- .../CloudFoundry/PermissionsProviderTest.cs | 14 ++++-- .../test/Endpoint.Test/CorsPolicyTest.cs | 1 + 8 files changed, 77 insertions(+), 46 deletions(-) diff --git a/src/Management/src/Abstractions/Configuration/EndpointPermissions.cs b/src/Management/src/Abstractions/Configuration/EndpointPermissions.cs index ce61c2964e..3e46238110 100644 --- a/src/Management/src/Abstractions/Configuration/EndpointPermissions.cs +++ b/src/Management/src/Abstractions/Configuration/EndpointPermissions.cs @@ -10,7 +10,7 @@ namespace Steeltoe.Management.Configuration; public enum EndpointPermissions { /// - /// Indicates no permission constraints. + /// Indicates no permissions. /// None, diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs index c44bfd35e9..239b8db618 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs @@ -4,8 +4,8 @@ using System.Net; using System.Net.Http.Headers; -using System.Security; using System.Text.Json; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -19,7 +19,6 @@ namespace Steeltoe.Management.Endpoint.Actuators.CloudFoundry; internal sealed partial class PermissionsProvider { - private const string ReadSensitiveDataJsonPropertyName = "read_sensitive_data"; public const string HttpClientName = "CloudFoundrySecurity"; private static readonly TimeSpan GetPermissionsTimeout = TimeSpan.FromMilliseconds(5_000); @@ -77,8 +76,11 @@ public async Task GetPermissionsAsync(string accessToken, Cancel : new SecurityResult(HttpStatusCode.ServiceUnavailable, Messages.CloudFoundryNotReachable); } - EndpointPermissions permissions = await ParsePermissionsResponseAsync(response, cancellationToken); - return new SecurityResult(permissions); + EndpointPermissions? permissions = await ParsePermissionsResponseAsync(response, cancellationToken); + + return permissions != null + ? new SecurityResult(permissions.Value) + : new SecurityResult(HttpStatusCode.BadGateway, Messages.CloudFoundryBrokenResponse); } catch (HttpRequestException exception) { @@ -91,34 +93,31 @@ public async Task GetPermissionsAsync(string accessToken, Cancel } } - public async Task ParsePermissionsResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) + public async Task ParsePermissionsResponseAsync(HttpResponseMessage response, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(response); - string json = string.Empty; - var permissions = EndpointPermissions.None; - try { - json = await response.Content.ReadAsStringAsync(cancellationToken); - + string json = await response.Content.ReadAsStringAsync(cancellationToken); LogResponseJson(SecurityUtilities.SanitizeInput(json)); - var result = JsonSerializer.Deserialize>(json); + var result = JsonSerializer.Deserialize(json); - if (result != null && result.TryGetValue(ReadSensitiveDataJsonPropertyName, out JsonElement permissionElement)) + EndpointPermissions permissions = result switch { - bool enabled = JsonSerializer.Deserialize(permissionElement.GetRawText()); - permissions = enabled ? EndpointPermissions.Full : EndpointPermissions.Restricted; - } + { ReadBasicData: true, ReadSensitiveData: true } => EndpointPermissions.Full, + { ReadBasicData: true, ReadSensitiveData: false } => EndpointPermissions.Restricted, + _ => EndpointPermissions.None + }; + + LogPermissions(permissions); + return permissions; } catch (Exception exception) when (!exception.IsCancellation()) { - throw new SecurityException($"Exception extracting permissions from json: {SecurityUtilities.SanitizeInput(json)}", exception); + return null; } - - LogPermissions(permissions); - return permissions; } private HttpClient CreateHttpClient() @@ -140,6 +139,15 @@ private HttpClient CreateHttpClient() [LoggerMessage(Level = LogLevel.Debug, Message = "Resolved permissions to {Permissions}.")] private partial void LogPermissions(EndpointPermissions permissions); + private sealed class PermissionsResponse + { + [JsonPropertyName("read_basic_data")] + public bool ReadBasicData { get; set; } + + [JsonPropertyName("read_sensitive_data")] + public bool ReadSensitiveData { get; set; } + } + internal static class Messages { public const string AccessDenied = "Access denied"; @@ -148,6 +156,7 @@ internal static class Messages public const string CloudFoundryApiMissing = "Cloud controller URL is not available"; public const string CloudFoundryNotReachable = "Cloud controller not reachable"; public const string CloudFoundryTimeout = "Cloud controller request timed out"; + public const string CloudFoundryBrokenResponse = "Failed to parse Cloud controller response"; public const string InvalidToken = "Invalid token"; } } diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs index 5d581d2d1b..3c7a6e22de 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs @@ -32,11 +32,17 @@ internal static DelegateToMockHttpClientHandler GetHttpMessageHandler() httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/exception/permissions") .Throw(new HttpRequestException(HttpRequestError.NameResolutionError)); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/no_sensitive_data/permissions").Respond(HttpStatusCode.OK, + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/broken-response/permissions") + .Respond(HttpStatusCode.OK, "application/json", "{"); + + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/no-permissions/permissions").Respond(HttpStatusCode.OK, "application/json", + """{"read_sensitive_data": false, "read_basic_data": false}"""); + + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/restricted-permissions/permissions").Respond(HttpStatusCode.OK, "application/json", """{"read_sensitive_data": false, "read_basic_data": true}"""); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/success/permissions").Respond(HttpStatusCode.OK, "application/json", - """{"read_sensitive_data": true, "read_basic_data": true}"""); + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/full-permissions/permissions").Respond(HttpStatusCode.OK, + "application/json", """{"read_sensitive_data": true, "read_basic_data": true}"""); return httpClientHandler; } diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundryActuatorTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundryActuatorTest.cs index 5484052d8a..14f42128c6 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundryActuatorTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundryActuatorTest.cs @@ -27,7 +27,7 @@ public sealed class CloudFoundryActuatorTest private const string VcapApplicationForMock = """ { "cf_api": "https://example.api.com", - "application_id": "success" + "application_id": "full-permissions" } """; diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs index 99a64e2782..18e350eb83 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTest.cs @@ -327,7 +327,7 @@ public async Task RedactsHttpHeaders() { var appSettings = new Dictionary { - ["vcap:application:application_id"] = "success", + ["vcap:application:application_id"] = "full-permissions", ["vcap:application:cf_api"] = "https://example.api.com" }; @@ -379,26 +379,26 @@ public async Task Returns_expected_response_on_permission_check(string scenario, await host.StartAsync(TestContext.Current.CancellationToken); using var client = new HttpClient(); - var testAuthenticationRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost:5000/cloudfoundryapplication")); - testAuthenticationRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken); - var testAuthorizationRequestMessage = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost:5000/cloudfoundryapplication/info")); - testAuthorizationRequestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken); + var authenticationRequest = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost:5000/cloudfoundryapplication")); + authenticationRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken); + var authorizationRequest = new HttpRequestMessage(HttpMethod.Get, new Uri("http://localhost:5000/cloudfoundryapplication/info")); + authorizationRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", MockAccessToken); - HttpResponseMessage response = await client.SendAsync(testAuthenticationRequestMessage, TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(steeltoeStatusCode); + HttpResponseMessage authenticationResponse = await client.SendAsync(authenticationRequest, TestContext.Current.CancellationToken); + authenticationResponse.StatusCode.Should().Be(steeltoeStatusCode); if (errorMessage != null) { string jsonErrorValue = JsonValue.Create(errorMessage).ToJsonString(); - string errorText = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + string errorText = await authenticationResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); errorText.Should().Be(steeltoeStatusCode == HttpStatusCode.InternalServerError ? errorMessage : $$"""{"security_error":{{jsonErrorValue}}}"""); } else { - string responseBody = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + string authenticationResponseBody = await authenticationResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); - responseBody.Should().BeJson(""" + authenticationResponseBody.Should().BeJson(""" { "type":"steeltoe", "_links":{ @@ -415,8 +415,8 @@ public async Task Returns_expected_response_on_permission_check(string scenario, """); } - HttpResponseMessage fullPermissionResponse = await client.SendAsync(testAuthorizationRequestMessage, TestContext.Current.CancellationToken); - fullPermissionResponse.StatusCode.Should().Be(scenario == "no_sensitive_data" ? HttpStatusCode.Forbidden : steeltoeStatusCode); + HttpResponseMessage authorizationResponse = await client.SendAsync(authorizationRequest, TestContext.Current.CancellationToken); + authorizationResponse.StatusCode.Should().Be(scenario == "restricted-permissions" ? HttpStatusCode.Forbidden : steeltoeStatusCode); string logLines = loggerProvider.GetAsText(); logLines.Should().ContainAll(expectedLogs); diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs index 7b7419395a..4ab86ca1be 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs @@ -10,8 +10,8 @@ namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry; internal sealed class CloudFoundrySecurityMiddlewareTestScenarios : TheoryData { - private const string SuccessLog = - "INFO System.Net.Http.HttpClient.CloudFoundrySecurity.ClientHandler: Sending HTTP request GET https://example.api.com/v2/apps/success/permissions"; + private const string FullPermissionsLog = + "INFO System.Net.Http.HttpClient.CloudFoundrySecurity.ClientHandler: Sending HTTP request GET https://example.api.com/v2/apps/full-permissions/permissions"; private static readonly string PermissionsCheckForbiddenLog = $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status Forbidden while obtaining permissions from https://example.api.com/v2/apps/forbidden/permissions."; @@ -25,6 +25,9 @@ internal sealed class CloudFoundrySecurityMiddlewareTestScenarios : TheoryData @@ -87,10 +91,10 @@ public async Task Returns_expected_response_on_permission_check(string scenario, switch (scenario) { - case "success": + case "full-permissions": result.Permissions.Should().Be(EndpointPermissions.Full); break; - case "no_sensitive_data": + case "restricted-permissions": result.Permissions.Should().Be(EndpointPermissions.Restricted); break; default: diff --git a/src/Management/test/Endpoint.Test/CorsPolicyTest.cs b/src/Management/test/Endpoint.Test/CorsPolicyTest.cs index 88ef4cef2c..c1ff824cd8 100644 --- a/src/Management/test/Endpoint.Test/CorsPolicyTest.cs +++ b/src/Management/test/Endpoint.Test/CorsPolicyTest.cs @@ -183,6 +183,7 @@ public async Task ConfiguresDefaultActuatorsCorsPolicyForGetRequestOnCloudFoundr mock.Expect(HttpMethod.Get, "https://api.cloud.com/v2/apps/798c2495-fe75-49b1-88da-b81197f2bf06/permissions") .WithHeaders("Authorization", $"bearer {token}").Respond("application/json", """ { + "read_basic_data": true, "read_sensitive_data": true } """); From 34914d0bf79e97cbffb150da4ca18672fd8545b2 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Thu, 5 Mar 2026 15:31:14 +0100 Subject: [PATCH 10/18] Switch to v3 of cloud controller API for permission checks --- .../CloudFoundry/PermissionsProvider.cs | 2 +- .../CloudControllerPermissionsMock.cs | 20 +++++++++---------- ...dFoundrySecurityMiddlewareTestScenarios.cs | 8 ++++---- .../test/Endpoint.Test/CorsPolicyTest.cs | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs index 239b8db618..8cb703dfa5 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs @@ -51,7 +51,7 @@ public async Task GetPermissionsAsync(string accessToken, Cancel } CloudFoundryEndpointOptions options = _optionsMonitor.CurrentValue; - string checkPermissionsUri = $"{options.Api}/v2/apps/{options.ApplicationId}/permissions"; + string checkPermissionsUri = $"{options.Api}/v3/apps/{options.ApplicationId}/permissions"; var request = new HttpRequestMessage(HttpMethod.Get, new Uri(checkPermissionsUri, UriKind.RelativeOrAbsolute)); var auth = new AuthenticationHeaderValue("bearer", accessToken); request.Headers.Authorization = auth; diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs index 3c7a6e22de..152b3d7713 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs @@ -14,34 +14,34 @@ internal static DelegateToMockHttpClientHandler GetHttpMessageHandler() { var httpClientHandler = new DelegateToMockHttpClientHandler(); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/unavailable/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/unavailable/permissions") .Respond(HttpStatusCode.ServiceUnavailable, "application/json", "{}"); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/not-found/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/not-found/permissions") .Respond(HttpStatusCode.NotFound, "application/json", "{}"); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/unauthorized/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/unauthorized/permissions") .Respond(HttpStatusCode.Unauthorized, "application/json", "{}"); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/forbidden/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/forbidden/permissions") .Respond(HttpStatusCode.Forbidden, "application/json", "{}"); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/timeout/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/timeout/permissions") .Throw(new OperationCanceledException(null, new TimeoutException())); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/exception/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/exception/permissions") .Throw(new HttpRequestException(HttpRequestError.NameResolutionError)); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/broken-response/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/broken-response/permissions") .Respond(HttpStatusCode.OK, "application/json", "{"); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/no-permissions/permissions").Respond(HttpStatusCode.OK, "application/json", + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/no-permissions/permissions").Respond(HttpStatusCode.OK, "application/json", """{"read_sensitive_data": false, "read_basic_data": false}"""); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/restricted-permissions/permissions").Respond(HttpStatusCode.OK, + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/restricted-permissions/permissions").Respond(HttpStatusCode.OK, "application/json", """{"read_sensitive_data": false, "read_basic_data": true}"""); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/full-permissions/permissions").Respond(HttpStatusCode.OK, + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/full-permissions/permissions").Respond(HttpStatusCode.OK, "application/json", """{"read_sensitive_data": true, "read_basic_data": true}"""); return httpClientHandler; diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs index 4ab86ca1be..24e13dd624 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs @@ -11,16 +11,16 @@ namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry; internal sealed class CloudFoundrySecurityMiddlewareTestScenarios : TheoryData { private const string FullPermissionsLog = - "INFO System.Net.Http.HttpClient.CloudFoundrySecurity.ClientHandler: Sending HTTP request GET https://example.api.com/v2/apps/full-permissions/permissions"; + "INFO System.Net.Http.HttpClient.CloudFoundrySecurity.ClientHandler: Sending HTTP request GET https://example.api.com/v3/apps/full-permissions/permissions"; private static readonly string PermissionsCheckForbiddenLog = - $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status Forbidden while obtaining permissions from https://example.api.com/v2/apps/forbidden/permissions."; + $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status Forbidden while obtaining permissions from https://example.api.com/v3/apps/forbidden/permissions."; private static readonly string PermissionsCheckUnauthorizedLog = - $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status Unauthorized while obtaining permissions from https://example.api.com/v2/apps/unauthorized/permissions."; + $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status Unauthorized while obtaining permissions from https://example.api.com/v3/apps/unauthorized/permissions."; private static readonly string PermissionsCheckNotFoundLog = - $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status NotFound while obtaining permissions from https://example.api.com/v2/apps/not-found/permissions."; + $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status NotFound while obtaining permissions from https://example.api.com/v3/apps/not-found/permissions."; private static readonly string MiddlewareForbiddenLog = $"FAIL {typeof(CloudFoundrySecurityMiddleware)}: Actuator security error with status Forbidden: '{Messages.AccessDenied}'."; diff --git a/src/Management/test/Endpoint.Test/CorsPolicyTest.cs b/src/Management/test/Endpoint.Test/CorsPolicyTest.cs index c1ff824cd8..12714fa2a1 100644 --- a/src/Management/test/Endpoint.Test/CorsPolicyTest.cs +++ b/src/Management/test/Endpoint.Test/CorsPolicyTest.cs @@ -180,7 +180,7 @@ public async Task ConfiguresDefaultActuatorsCorsPolicyForGetRequestOnCloudFoundr DelegateToMockHttpClientHandler handler = new DelegateToMockHttpClientHandler().Setup(mock => { - mock.Expect(HttpMethod.Get, "https://api.cloud.com/v2/apps/798c2495-fe75-49b1-88da-b81197f2bf06/permissions") + mock.Expect(HttpMethod.Get, "https://api.cloud.com/v3/apps/798c2495-fe75-49b1-88da-b81197f2bf06/permissions") .WithHeaders("Authorization", $"bearer {token}").Respond("application/json", """ { "read_basic_data": true, From 3f9bfd1e789aa397380de45de47e5aebfba61deb Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:46:11 +0100 Subject: [PATCH 11/18] CloudFoundry actuator middleware: Use JSON source generator --- .../CloudFoundryJsonSerializerContext.cs | 12 ++++++++++++ .../CloudFoundry/CloudFoundrySecurityMiddleware.cs | 2 +- .../Actuators/CloudFoundry/PermissionsProvider.cs | 4 ++-- 3 files changed, 15 insertions(+), 3 deletions(-) create mode 100644 src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundryJsonSerializerContext.cs diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundryJsonSerializerContext.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundryJsonSerializerContext.cs new file mode 100644 index 0000000000..91b49a4f3c --- /dev/null +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundryJsonSerializerContext.cs @@ -0,0 +1,12 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Steeltoe.Management.Endpoint.Actuators.CloudFoundry; + +[JsonSourceGenerationOptions] +[JsonSerializable(typeof(PermissionsProvider.PermissionsResponse))] +[JsonSerializable(typeof(SecurityResult))] +internal sealed partial class CloudFoundryJsonSerializerContext : JsonSerializerContext; diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs index c8734d6ed0..9fedf69e56 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/CloudFoundrySecurityMiddleware.cs @@ -171,7 +171,7 @@ private async Task ReturnErrorAsync(HttpContext context, SecurityResult error) context.Response.StatusCode = (int)error.Code; } - await JsonSerializer.SerializeAsync(context.Response.Body, error, cancellationToken: context.RequestAborted); + await JsonSerializer.SerializeAsync(context.Response.Body, error, CloudFoundryJsonSerializerContext.Default.SecurityResult, context.RequestAborted); } [LoggerMessage(Level = LogLevel.Debug, Message = "Entering Cloud Foundry Security middleware at path {RequestPath}.")] diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs index 8cb703dfa5..c5f0243343 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs @@ -102,7 +102,7 @@ public async Task GetPermissionsAsync(string accessToken, Cancel string json = await response.Content.ReadAsStringAsync(cancellationToken); LogResponseJson(SecurityUtilities.SanitizeInput(json)); - var result = JsonSerializer.Deserialize(json); + PermissionsResponse? result = JsonSerializer.Deserialize(json, CloudFoundryJsonSerializerContext.Default.PermissionsResponse); EndpointPermissions permissions = result switch { @@ -139,7 +139,7 @@ private HttpClient CreateHttpClient() [LoggerMessage(Level = LogLevel.Debug, Message = "Resolved permissions to {Permissions}.")] private partial void LogPermissions(EndpointPermissions permissions); - private sealed class PermissionsResponse + internal sealed class PermissionsResponse { [JsonPropertyName("read_basic_data")] public bool ReadBasicData { get; set; } From 1907b898eaa6e71d31555131c680faf2dfda7bce Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Thu, 5 Mar 2026 17:53:15 +0100 Subject: [PATCH 12/18] Actuator port middleware: Use JSON source generator --- .../ManagementPort/ManagementPortMiddleware.cs | 4 ++-- .../PortMappingJsonSerializerContext.cs | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 src/Management/src/Endpoint/ManagementPort/PortMappingJsonSerializerContext.cs diff --git a/src/Management/src/Endpoint/ManagementPort/ManagementPortMiddleware.cs b/src/Management/src/Endpoint/ManagementPort/ManagementPortMiddleware.cs index 6ba88fb103..cd94e77298 100644 --- a/src/Management/src/Endpoint/ManagementPort/ManagementPortMiddleware.cs +++ b/src/Management/src/Endpoint/ManagementPort/ManagementPortMiddleware.cs @@ -74,7 +74,7 @@ private bool HasMappedInstancePort(int managementPort, int? requestPort) if (!string.IsNullOrEmpty(instancePorts)) { - var portMappings = JsonSerializer.Deserialize>(instancePorts); + List? portMappings = JsonSerializer.Deserialize(instancePorts, PortMappingJsonSerializerContext.Default.ListPortMapping); PortMapping? portMapping = portMappings?.Find(mapping => mapping.Internal == managementPort && (requestPort == mapping.ExternalTlsProxy || requestPort == mapping.InternalTlsProxy)); @@ -116,7 +116,7 @@ private void SetResponseError(HttpContext context, int managementPort) Message = "Access to {Path} on port {Port} denied because 'Management:Endpoints:Port' is set to {ManagementPort}.")] private partial void LogAccessDenied(PathString path, int? port, int managementPort); - private sealed record PortMapping + internal sealed record PortMapping { [JsonPropertyName("internal")] public int? Internal { get; init; } diff --git a/src/Management/src/Endpoint/ManagementPort/PortMappingJsonSerializerContext.cs b/src/Management/src/Endpoint/ManagementPort/PortMappingJsonSerializerContext.cs new file mode 100644 index 0000000000..1e59b524ed --- /dev/null +++ b/src/Management/src/Endpoint/ManagementPort/PortMappingJsonSerializerContext.cs @@ -0,0 +1,11 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +using System.Text.Json.Serialization; + +namespace Steeltoe.Management.Endpoint.ManagementPort; + +[JsonSourceGenerationOptions] +[JsonSerializable(typeof(List))] +internal sealed partial class PortMappingJsonSerializerContext : JsonSerializerContext; From 916c251a1ebbab019917cb7cf40a033144b2effc Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:44:25 +0100 Subject: [PATCH 13/18] Loggers actuator fixes: Respect serializer options when reading request body, correct nullability, correct invalid tests (don't assume RESET command if unable to parse request body) --- .../Loggers/LoggersEndpointMiddleware.cs | 30 ++++++++++--------- .../Endpoint.Test/ContentNegotiationTest.cs | 21 +++++++++++-- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/Management/src/Endpoint/Actuators/Loggers/LoggersEndpointMiddleware.cs b/src/Management/src/Endpoint/Actuators/Loggers/LoggersEndpointMiddleware.cs index 30427ca776..747f26a17e 100644 --- a/src/Management/src/Endpoint/Actuators/Loggers/LoggersEndpointMiddleware.cs +++ b/src/Management/src/Endpoint/Actuators/Loggers/LoggersEndpointMiddleware.cs @@ -31,23 +31,24 @@ internal sealed partial class LoggersEndpointMiddleware( if (httpContext.Request.Path.StartsWithSegments(path, out PathString remaining) && remaining.HasValue) { - string loggerName = remaining.Value!.TrimStart('/'); + string loggerName = remaining.Value.TrimStart('/'); - Dictionary change = await DeserializeRequestAsync(httpContext.Request.Body, cancellationToken); + Dictionary changes = await DeserializeRequestAsync(httpContext.Request.Body, cancellationToken); - change.TryGetValue("configuredLevel", out string? level); - - LogChangeRequest(loggerName, level ?? "RESET"); - - if (!string.IsNullOrEmpty(loggerName)) + if (changes.TryGetValue("configuredLevel", out string? level)) { - if (!string.IsNullOrEmpty(level) && LoggerLevels.StringToLogLevel(level) == null) + LogChangeRequest(loggerName, level ?? "RESET"); + + if (!string.IsNullOrEmpty(loggerName)) { - LogInvalidLevel(level); - return null; - } + if (!string.IsNullOrEmpty(level) && LoggerLevels.StringToLogLevel(level) == null) + { + LogInvalidLevel(level); + return null; + } - return new LoggersRequest(loggerName, level); + return new LoggersRequest(loggerName, level); + } } } } @@ -55,11 +56,12 @@ internal sealed partial class LoggersEndpointMiddleware( return new LoggersRequest(); } - private async Task> DeserializeRequestAsync(Stream stream, CancellationToken cancellationToken) + private async Task> DeserializeRequestAsync(Stream stream, CancellationToken cancellationToken) { try { - var dictionary = await JsonSerializer.DeserializeAsync>(stream, cancellationToken: cancellationToken); + JsonSerializerOptions options = ManagementOptionsMonitor.CurrentValue.SerializerOptions; + var dictionary = await JsonSerializer.DeserializeAsync>(stream, options, cancellationToken); if (dictionary != null) { diff --git a/src/Management/test/Endpoint.Test/ContentNegotiationTest.cs b/src/Management/test/Endpoint.Test/ContentNegotiationTest.cs index e3647e9e31..700286a0ea 100644 --- a/src/Management/test/Endpoint.Test/ContentNegotiationTest.cs +++ b/src/Management/test/Endpoint.Test/ContentNegotiationTest.cs @@ -34,7 +34,12 @@ public async Task Can_use_content_type_with_alternate_casing() using HttpClient client = host.GetTestClient(); MediaTypeHeaderValue contentType = MediaTypeHeaderValue.Parse("APPLICATION/vnd.Spring-Boot.Actuator.v3+JSON"); - HttpContent requestContent = new StringContent("{}", contentType); + + HttpContent requestContent = new StringContent(""" + { + "configuredLevel": null + } + """, contentType); HttpResponseMessage response = await client.PostAsync(new Uri("http://localhost/actuator/loggers/Default"), requestContent, TestContext.Current.CancellationToken); @@ -54,7 +59,12 @@ public async Task Can_use_content_type_including_charset() using HttpClient client = host.GetTestClient(); MediaTypeHeaderValue contentType = MediaTypeHeaderValue.Parse("application/vnd.spring-boot.actuator.v3+json; charset=utf-8"); - HttpContent requestContent = new StringContent("{}", contentType); + + HttpContent requestContent = new StringContent(""" + { + "configuredLevel": null + } + """, contentType); HttpResponseMessage response = await client.PostAsync(new Uri("http://localhost/actuator/loggers/Default"), requestContent, TestContext.Current.CancellationToken); @@ -74,7 +84,12 @@ public async Task Cannot_use_invalid_content_type() using HttpClient client = host.GetTestClient(); MediaTypeHeaderValue contentType = MediaTypeHeaderValue.Parse("application/xhtml+xml"); - HttpContent requestContent = new StringContent("{}", contentType); + + HttpContent requestContent = new StringContent(""" + { + "configuredLevel": null + } + """, contentType); HttpResponseMessage response = await client.PostAsync(new Uri("http://localhost/actuator/loggers/Default"), requestContent, TestContext.Current.CancellationToken); From 2d1d38bac6c474b3b90d5ebcbe76e13dfd507e8f Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:17:58 +0100 Subject: [PATCH 14/18] Add missing [JsonPropertyName] to JSON-serialized types because 1) this guards against wire-level breaking changes when renaming properties and 2) the names are dictated by Spring, so configuring a global casing policy must not change them --- .../src/Common/HealthChecks/HealthCheckResult.cs | 3 +++ .../Actuators/DbMigrations/DbMigrationsDescriptor.cs | 5 +++++ .../Actuators/Health/HealthEndpointRequest.cs | 5 +++++ .../Actuators/Health/HealthEndpointResponse.cs | 4 ++++ .../Endpoint/Actuators/HttpExchanges/HttpExchange.cs | 9 +++++++++ .../Actuators/HttpExchanges/HttpExchangePrincipal.cs | 3 +++ .../Actuators/HttpExchanges/HttpExchangeRequest.cs | 6 ++++++ .../Actuators/HttpExchanges/HttpExchangeResponse.cs | 3 +++ .../Actuators/HttpExchanges/HttpExchangeSession.cs | 3 +++ .../Actuators/HttpExchanges/HttpExchangesResult.cs | 2 ++ .../src/Endpoint/Actuators/Hypermedia/Link.cs | 1 + .../src/Endpoint/Actuators/Hypermedia/Links.cs | 1 + .../TestContributors/ComplexDetailsContributor.cs | 12 ++++++++++++ 13 files changed, 57 insertions(+) diff --git a/src/Common/src/Common/HealthChecks/HealthCheckResult.cs b/src/Common/src/Common/HealthChecks/HealthCheckResult.cs index 473c5e9936..8d967097e6 100644 --- a/src/Common/src/Common/HealthChecks/HealthCheckResult.cs +++ b/src/Common/src/Common/HealthChecks/HealthCheckResult.cs @@ -19,6 +19,7 @@ public sealed class HealthCheckResult /// /// Used by the health middleware to determine the HTTP Status code. /// + [JsonPropertyName("status")] [JsonConverter(typeof(SnakeCaseAllCapsEnumMemberJsonConverter))] public HealthStatus Status { get; set; } = HealthStatus.Unknown; @@ -28,11 +29,13 @@ public sealed class HealthCheckResult /// /// Currently only used on check failures. /// + [JsonPropertyName("description")] public string? Description { get; set; } /// /// Gets details of the health check. /// + [JsonPropertyName("details")] [JsonIgnoreEmptyCollection] public IDictionary Details { get; } = new Dictionary(); } diff --git a/src/Management/src/Endpoint/Actuators/DbMigrations/DbMigrationsDescriptor.cs b/src/Management/src/Endpoint/Actuators/DbMigrations/DbMigrationsDescriptor.cs index 656246f636..17f78cceb1 100644 --- a/src/Management/src/Endpoint/Actuators/DbMigrations/DbMigrationsDescriptor.cs +++ b/src/Management/src/Endpoint/Actuators/DbMigrations/DbMigrationsDescriptor.cs @@ -2,10 +2,15 @@ // The .NET Foundation licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information. +using System.Text.Json.Serialization; + namespace Steeltoe.Management.Endpoint.Actuators.DbMigrations; public sealed class DbMigrationsDescriptor { + [JsonPropertyName("pendingMigrations")] public IList PendingMigrations { get; } = new List(); + + [JsonPropertyName("appliedMigrations")] public IList AppliedMigrations { get; } = new List(); } diff --git a/src/Management/src/Endpoint/Actuators/Health/HealthEndpointRequest.cs b/src/Management/src/Endpoint/Actuators/Health/HealthEndpointRequest.cs index fd1fe47c94..dc593685fd 100644 --- a/src/Management/src/Endpoint/Actuators/Health/HealthEndpointRequest.cs +++ b/src/Management/src/Endpoint/Actuators/Health/HealthEndpointRequest.cs @@ -2,11 +2,16 @@ // The .NET Foundation licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information. +using System.Text.Json.Serialization; + namespace Steeltoe.Management.Endpoint.Actuators.Health; public sealed class HealthEndpointRequest { + [JsonPropertyName("groupName")] public string GroupName { get; } + + [JsonPropertyName("hasClaim")] public bool HasClaim { get; } public HealthEndpointRequest(string groupName, bool hasClaim) diff --git a/src/Management/src/Endpoint/Actuators/Health/HealthEndpointResponse.cs b/src/Management/src/Endpoint/Actuators/Health/HealthEndpointResponse.cs index 231d84fc53..09e734c9c5 100644 --- a/src/Management/src/Endpoint/Actuators/Health/HealthEndpointResponse.cs +++ b/src/Management/src/Endpoint/Actuators/Health/HealthEndpointResponse.cs @@ -14,17 +14,20 @@ public sealed class HealthEndpointResponse /// /// Gets the status of the health check. /// + [JsonPropertyName("status")] [JsonConverter(typeof(SnakeCaseAllCapsEnumMemberJsonConverter))] public HealthStatus Status { get; init; } /// /// Gets a description of the health check result. /// + [JsonPropertyName("description")] public string? Description { get; init; } /// /// Gets the individual health check components, including their details. /// + [JsonPropertyName("components")] [JsonIgnoreEmptyCollection] public IDictionary Components { get; } = new Dictionary(); @@ -32,6 +35,7 @@ public sealed class HealthEndpointResponse /// Gets the list of available health groups. /// [JsonIgnoreEmptyCollection] + [JsonPropertyName("groups")] public IList Groups { get; } = new List(); /// diff --git a/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchange.cs b/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchange.cs index ef97df0eea..1ca2564a46 100644 --- a/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchange.cs +++ b/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchange.cs @@ -12,10 +12,19 @@ public sealed class HttpExchange [JsonPropertyName("timeTaken")] public string? SerializedTimeTaken => TimeTaken != null ? XmlConvert.ToString(TimeTaken.Value) : null; + [JsonPropertyName("timestamp")] public DateTime Timestamp { get; } + + [JsonPropertyName("principal")] public HttpExchangePrincipal? Principal { get; } + + [JsonPropertyName("session")] public HttpExchangeSession? Session { get; } + + [JsonPropertyName("request")] public HttpExchangeRequest Request { get; } + + [JsonPropertyName("response")] public HttpExchangeResponse Response { get; } [JsonIgnore] diff --git a/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangePrincipal.cs b/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangePrincipal.cs index 2e7f976c12..f8e870963a 100644 --- a/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangePrincipal.cs +++ b/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangePrincipal.cs @@ -2,10 +2,13 @@ // The .NET Foundation licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information. +using System.Text.Json.Serialization; + namespace Steeltoe.Management.Endpoint.Actuators.HttpExchanges; public sealed class HttpExchangePrincipal { + [JsonPropertyName("name")] public string Name { get; } public HttpExchangePrincipal(string name) diff --git a/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangeRequest.cs b/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangeRequest.cs index ddce7c1e83..2f8d0bc53a 100644 --- a/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangeRequest.cs +++ b/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangeRequest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information. +using System.Text.Json.Serialization; using Microsoft.Extensions.Primitives; using Steeltoe.Common.Json; @@ -9,12 +10,17 @@ namespace Steeltoe.Management.Endpoint.Actuators.HttpExchanges; public sealed class HttpExchangeRequest { + [JsonPropertyName("method")] public string Method { get; } + + [JsonPropertyName("uri")] public Uri Uri { get; } + [JsonPropertyName("headers")] [JsonIgnoreEmptyCollection] public IDictionary Headers { get; } + [JsonPropertyName("remoteAddress")] public string? RemoteAddress { get; } public HttpExchangeRequest(string method, Uri uri, IDictionary headers, string? remoteAddress) diff --git a/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangeResponse.cs b/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangeResponse.cs index 63e6338a98..4ecf185834 100644 --- a/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangeResponse.cs +++ b/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangeResponse.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information. +using System.Text.Json.Serialization; using Microsoft.Extensions.Primitives; using Steeltoe.Common.Json; @@ -9,8 +10,10 @@ namespace Steeltoe.Management.Endpoint.Actuators.HttpExchanges; public sealed class HttpExchangeResponse { + [JsonPropertyName("status")] public int Status { get; } + [JsonPropertyName("headers")] [JsonIgnoreEmptyCollection] public IDictionary Headers { get; } diff --git a/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangeSession.cs b/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangeSession.cs index 33ed0af39c..b2ad567174 100644 --- a/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangeSession.cs +++ b/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangeSession.cs @@ -2,10 +2,13 @@ // The .NET Foundation licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information. +using System.Text.Json.Serialization; + namespace Steeltoe.Management.Endpoint.Actuators.HttpExchanges; public sealed class HttpExchangeSession { + [JsonPropertyName("id")] public string Id { get; } public HttpExchangeSession(string id) diff --git a/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangesResult.cs b/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangesResult.cs index d687f08313..32bc9a2505 100644 --- a/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangesResult.cs +++ b/src/Management/src/Endpoint/Actuators/HttpExchanges/HttpExchangesResult.cs @@ -2,12 +2,14 @@ // The .NET Foundation licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information. +using System.Text.Json.Serialization; using Steeltoe.Common; namespace Steeltoe.Management.Endpoint.Actuators.HttpExchanges; public sealed class HttpExchangesResult { + [JsonPropertyName("exchanges")] public IList Exchanges { get; } public HttpExchangesResult(IList exchanges) diff --git a/src/Management/src/Endpoint/Actuators/Hypermedia/Link.cs b/src/Management/src/Endpoint/Actuators/Hypermedia/Link.cs index a6cb952bbe..05fb09a975 100644 --- a/src/Management/src/Endpoint/Actuators/Hypermedia/Link.cs +++ b/src/Management/src/Endpoint/Actuators/Hypermedia/Link.cs @@ -8,6 +8,7 @@ namespace Steeltoe.Management.Endpoint.Actuators.Hypermedia; public sealed class Link { + [JsonPropertyName("href")] public string Href { get; set; } [JsonPropertyName("templated")] diff --git a/src/Management/src/Endpoint/Actuators/Hypermedia/Links.cs b/src/Management/src/Endpoint/Actuators/Hypermedia/Links.cs index 6a748f1c6c..777f7f5e93 100644 --- a/src/Management/src/Endpoint/Actuators/Hypermedia/Links.cs +++ b/src/Management/src/Endpoint/Actuators/Hypermedia/Links.cs @@ -14,6 +14,7 @@ public sealed class Links /// /// Gets or sets the type of links contained in this collection. /// + [JsonPropertyName("type")] public string Type { get; set; } = "steeltoe"; /// diff --git a/src/Management/test/Endpoint.Test/Actuators/Health/TestContributors/ComplexDetailsContributor.cs b/src/Management/test/Endpoint.Test/Actuators/Health/TestContributors/ComplexDetailsContributor.cs index b23a8cd54c..b3bffabe72 100644 --- a/src/Management/test/Endpoint.Test/Actuators/Health/TestContributors/ComplexDetailsContributor.cs +++ b/src/Management/test/Endpoint.Test/Actuators/Health/TestContributors/ComplexDetailsContributor.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information. +using System.Text.Json.Serialization; using Steeltoe.Common.HealthChecks; namespace Steeltoe.Management.Endpoint.Test.Actuators.Health.TestContributors; @@ -38,12 +39,22 @@ internal sealed class ComplexDetailsContributor : IHealthContributor private sealed class TestHealthDetails { + [JsonPropertyName("testString")] public string TestString { get; set; } = "test-string"; + + [JsonPropertyName("testInteger")] public int TestInteger { get; set; } = 123; + + [JsonPropertyName("testFloatingPoint")] public double TestFloatingPoint { get; set; } = 1.23; + + [JsonPropertyName("testBoolean")] public bool TestBoolean { get; set; } = true; + + [JsonPropertyName("nestedComplexType")] public TestHealthDetails? NestedComplexType { get; set; } + [JsonPropertyName("testList")] public List TestList { get; set; } = [ "A", @@ -51,6 +62,7 @@ private sealed class TestHealthDetails "C" ]; + [JsonPropertyName("testDictionary")] public Dictionary TestDictionary { get; set; } = new() { ["One"] = 1, From 93d79bbd180f6b07c64af9fe1c2c45866aa23aa1 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Fri, 6 Mar 2026 17:27:18 +0100 Subject: [PATCH 15/18] Revert "Switch to v3 of cloud controller API for permission checks" This reverts commit 34914d0bf79e97cbffb150da4ca18672fd8545b2. --- .../CloudFoundry/PermissionsProvider.cs | 2 +- .../CloudControllerPermissionsMock.cs | 20 +++++++++---------- ...dFoundrySecurityMiddlewareTestScenarios.cs | 8 ++++---- .../test/Endpoint.Test/CorsPolicyTest.cs | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs index c5f0243343..ba03042926 100644 --- a/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs +++ b/src/Management/src/Endpoint/Actuators/CloudFoundry/PermissionsProvider.cs @@ -51,7 +51,7 @@ public async Task GetPermissionsAsync(string accessToken, Cancel } CloudFoundryEndpointOptions options = _optionsMonitor.CurrentValue; - string checkPermissionsUri = $"{options.Api}/v3/apps/{options.ApplicationId}/permissions"; + string checkPermissionsUri = $"{options.Api}/v2/apps/{options.ApplicationId}/permissions"; var request = new HttpRequestMessage(HttpMethod.Get, new Uri(checkPermissionsUri, UriKind.RelativeOrAbsolute)); var auth = new AuthenticationHeaderValue("bearer", accessToken); request.Headers.Authorization = auth; diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs index 152b3d7713..3c7a6e22de 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudControllerPermissionsMock.cs @@ -14,34 +14,34 @@ internal static DelegateToMockHttpClientHandler GetHttpMessageHandler() { var httpClientHandler = new DelegateToMockHttpClientHandler(); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/unavailable/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/unavailable/permissions") .Respond(HttpStatusCode.ServiceUnavailable, "application/json", "{}"); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/not-found/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/not-found/permissions") .Respond(HttpStatusCode.NotFound, "application/json", "{}"); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/unauthorized/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/unauthorized/permissions") .Respond(HttpStatusCode.Unauthorized, "application/json", "{}"); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/forbidden/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/forbidden/permissions") .Respond(HttpStatusCode.Forbidden, "application/json", "{}"); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/timeout/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/timeout/permissions") .Throw(new OperationCanceledException(null, new TimeoutException())); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/exception/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/exception/permissions") .Throw(new HttpRequestException(HttpRequestError.NameResolutionError)); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/broken-response/permissions") + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/broken-response/permissions") .Respond(HttpStatusCode.OK, "application/json", "{"); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/no-permissions/permissions").Respond(HttpStatusCode.OK, "application/json", + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/no-permissions/permissions").Respond(HttpStatusCode.OK, "application/json", """{"read_sensitive_data": false, "read_basic_data": false}"""); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/restricted-permissions/permissions").Respond(HttpStatusCode.OK, + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/restricted-permissions/permissions").Respond(HttpStatusCode.OK, "application/json", """{"read_sensitive_data": false, "read_basic_data": true}"""); - httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v3/apps/full-permissions/permissions").Respond(HttpStatusCode.OK, + httpClientHandler.Mock.When(HttpMethod.Get, "https://example.api.com/v2/apps/full-permissions/permissions").Respond(HttpStatusCode.OK, "application/json", """{"read_sensitive_data": true, "read_basic_data": true}"""); return httpClientHandler; diff --git a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs index 24e13dd624..4ab86ca1be 100644 --- a/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs +++ b/src/Management/test/Endpoint.Test/Actuators/CloudFoundry/CloudFoundrySecurityMiddlewareTestScenarios.cs @@ -11,16 +11,16 @@ namespace Steeltoe.Management.Endpoint.Test.Actuators.CloudFoundry; internal sealed class CloudFoundrySecurityMiddlewareTestScenarios : TheoryData { private const string FullPermissionsLog = - "INFO System.Net.Http.HttpClient.CloudFoundrySecurity.ClientHandler: Sending HTTP request GET https://example.api.com/v3/apps/full-permissions/permissions"; + "INFO System.Net.Http.HttpClient.CloudFoundrySecurity.ClientHandler: Sending HTTP request GET https://example.api.com/v2/apps/full-permissions/permissions"; private static readonly string PermissionsCheckForbiddenLog = - $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status Forbidden while obtaining permissions from https://example.api.com/v3/apps/forbidden/permissions."; + $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status Forbidden while obtaining permissions from https://example.api.com/v2/apps/forbidden/permissions."; private static readonly string PermissionsCheckUnauthorizedLog = - $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status Unauthorized while obtaining permissions from https://example.api.com/v3/apps/unauthorized/permissions."; + $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status Unauthorized while obtaining permissions from https://example.api.com/v2/apps/unauthorized/permissions."; private static readonly string PermissionsCheckNotFoundLog = - $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status NotFound while obtaining permissions from https://example.api.com/v3/apps/not-found/permissions."; + $"INFO {typeof(PermissionsProvider)}: Cloud Foundry returned status NotFound while obtaining permissions from https://example.api.com/v2/apps/not-found/permissions."; private static readonly string MiddlewareForbiddenLog = $"FAIL {typeof(CloudFoundrySecurityMiddleware)}: Actuator security error with status Forbidden: '{Messages.AccessDenied}'."; diff --git a/src/Management/test/Endpoint.Test/CorsPolicyTest.cs b/src/Management/test/Endpoint.Test/CorsPolicyTest.cs index 12714fa2a1..c1ff824cd8 100644 --- a/src/Management/test/Endpoint.Test/CorsPolicyTest.cs +++ b/src/Management/test/Endpoint.Test/CorsPolicyTest.cs @@ -180,7 +180,7 @@ public async Task ConfiguresDefaultActuatorsCorsPolicyForGetRequestOnCloudFoundr DelegateToMockHttpClientHandler handler = new DelegateToMockHttpClientHandler().Setup(mock => { - mock.Expect(HttpMethod.Get, "https://api.cloud.com/v3/apps/798c2495-fe75-49b1-88da-b81197f2bf06/permissions") + mock.Expect(HttpMethod.Get, "https://api.cloud.com/v2/apps/798c2495-fe75-49b1-88da-b81197f2bf06/permissions") .WithHeaders("Authorization", $"bearer {token}").Respond("application/json", """ { "read_basic_data": true, From 70d1123b342dfe01709e70923785e0705b84de85 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:58:33 +0100 Subject: [PATCH 16/18] Fix broken log level reset in Spring Boot Admin The proper way to RESET is sending an empty JSON object, see https://docs.spring.io/spring-boot/api/rest/actuator/loggers.html. --- .../Loggers/LoggersEndpointMiddleware.cs | 22 +++++++++---------- .../Loggers/LoggersActuatorSerilogTest.cs | 7 ++---- .../Actuators/Loggers/LoggersActuatorTest.cs | 7 ++---- .../Endpoint.Test/ContentNegotiationTest.cs | 18 +++------------ 4 files changed, 18 insertions(+), 36 deletions(-) diff --git a/src/Management/src/Endpoint/Actuators/Loggers/LoggersEndpointMiddleware.cs b/src/Management/src/Endpoint/Actuators/Loggers/LoggersEndpointMiddleware.cs index 747f26a17e..26cc5dc7a3 100644 --- a/src/Management/src/Endpoint/Actuators/Loggers/LoggersEndpointMiddleware.cs +++ b/src/Management/src/Endpoint/Actuators/Loggers/LoggersEndpointMiddleware.cs @@ -35,20 +35,20 @@ internal sealed partial class LoggersEndpointMiddleware( Dictionary changes = await DeserializeRequestAsync(httpContext.Request.Body, cancellationToken); - if (changes.TryGetValue("configuredLevel", out string? level)) - { - LogChangeRequest(loggerName, level ?? "RESET"); + // Client sends an empty JSON object to reset the level. + _ = changes.TryGetValue("configuredLevel", out string? level); - if (!string.IsNullOrEmpty(loggerName)) - { - if (!string.IsNullOrEmpty(level) && LoggerLevels.StringToLogLevel(level) == null) - { - LogInvalidLevel(level); - return null; - } + LogChangeRequest(loggerName, level ?? "RESET"); - return new LoggersRequest(loggerName, level); + if (!string.IsNullOrEmpty(loggerName)) + { + if (!string.IsNullOrEmpty(level) && LoggerLevels.StringToLogLevel(level) == null) + { + LogInvalidLevel(level); + return null; } + + return new LoggersRequest(loggerName, level); } } } diff --git a/src/Management/test/Endpoint.Test/Actuators/Loggers/LoggersActuatorSerilogTest.cs b/src/Management/test/Endpoint.Test/Actuators/Loggers/LoggersActuatorSerilogTest.cs index 02ccd12aca..70e96e837a 100644 --- a/src/Management/test/Endpoint.Test/Actuators/Loggers/LoggersActuatorSerilogTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/Loggers/LoggersActuatorSerilogTest.cs @@ -214,11 +214,8 @@ public async Task Can_change_minimum_levels_with_serilog() } """); - HttpResponseMessage resetResponse = await httpClient.PostAsync(new Uri("http://localhost/actuator/loggers/Fake.Category"), new StringContent(""" - { - "configuredLevel": null - } - """, RequestContentType), TestContext.Current.CancellationToken); + HttpResponseMessage resetResponse = await httpClient.PostAsync(new Uri("http://localhost/actuator/loggers/Fake.Category"), + new StringContent("{}", RequestContentType), TestContext.Current.CancellationToken); resetResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); (await resetResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).Should().BeEmpty(); diff --git a/src/Management/test/Endpoint.Test/Actuators/Loggers/LoggersActuatorTest.cs b/src/Management/test/Endpoint.Test/Actuators/Loggers/LoggersActuatorTest.cs index c5d72935cb..6da4f217e8 100644 --- a/src/Management/test/Endpoint.Test/Actuators/Loggers/LoggersActuatorTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/Loggers/LoggersActuatorTest.cs @@ -289,11 +289,8 @@ public async Task Can_change_minimum_levels() } """); - HttpResponseMessage resetResponse = await httpClient.PostAsync(new Uri("http://localhost/actuator/loggers/Fake.Category"), new StringContent(""" - { - "configuredLevel": null - } - """, RequestContentType), TestContext.Current.CancellationToken); + HttpResponseMessage resetResponse = await httpClient.PostAsync(new Uri("http://localhost/actuator/loggers/Fake.Category"), + new StringContent("{}", RequestContentType), TestContext.Current.CancellationToken); resetResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); (await resetResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).Should().BeEmpty(); diff --git a/src/Management/test/Endpoint.Test/ContentNegotiationTest.cs b/src/Management/test/Endpoint.Test/ContentNegotiationTest.cs index 700286a0ea..a9f20d2358 100644 --- a/src/Management/test/Endpoint.Test/ContentNegotiationTest.cs +++ b/src/Management/test/Endpoint.Test/ContentNegotiationTest.cs @@ -35,11 +35,7 @@ public async Task Can_use_content_type_with_alternate_casing() using HttpClient client = host.GetTestClient(); MediaTypeHeaderValue contentType = MediaTypeHeaderValue.Parse("APPLICATION/vnd.Spring-Boot.Actuator.v3+JSON"); - HttpContent requestContent = new StringContent(""" - { - "configuredLevel": null - } - """, contentType); + HttpContent requestContent = new StringContent("{}", contentType); HttpResponseMessage response = await client.PostAsync(new Uri("http://localhost/actuator/loggers/Default"), requestContent, TestContext.Current.CancellationToken); @@ -60,11 +56,7 @@ public async Task Can_use_content_type_including_charset() using HttpClient client = host.GetTestClient(); MediaTypeHeaderValue contentType = MediaTypeHeaderValue.Parse("application/vnd.spring-boot.actuator.v3+json; charset=utf-8"); - HttpContent requestContent = new StringContent(""" - { - "configuredLevel": null - } - """, contentType); + HttpContent requestContent = new StringContent("{}", contentType); HttpResponseMessage response = await client.PostAsync(new Uri("http://localhost/actuator/loggers/Default"), requestContent, TestContext.Current.CancellationToken); @@ -85,11 +77,7 @@ public async Task Cannot_use_invalid_content_type() using HttpClient client = host.GetTestClient(); MediaTypeHeaderValue contentType = MediaTypeHeaderValue.Parse("application/xhtml+xml"); - HttpContent requestContent = new StringContent(""" - { - "configuredLevel": null - } - """, contentType); + HttpContent requestContent = new StringContent("{}", contentType); HttpResponseMessage response = await client.PostAsync(new Uri("http://localhost/actuator/loggers/Default"), requestContent, TestContext.Current.CancellationToken); From ad5690b5ad8058b8b90d402121a855033b56c736 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:30:27 +0100 Subject: [PATCH 17/18] Revert "SpringBootAdmin client: Use JSON source generator" This reverts commit ad5898f9f757d625e88509306774a1c539788390. --- .../SpringBootAdminApiClient.cs | 8 ++--- .../SpringBootAdminJsonSerializerContext.cs | 29 ------------------- .../SpringBootAdminClient/HostBuilderTest.cs | 4 +-- 3 files changed, 4 insertions(+), 37 deletions(-) delete mode 100644 src/Management/src/Endpoint/SpringBootAdminClient/SpringBootAdminJsonSerializerContext.cs diff --git a/src/Management/src/Endpoint/SpringBootAdminClient/SpringBootAdminApiClient.cs b/src/Management/src/Endpoint/SpringBootAdminClient/SpringBootAdminApiClient.cs index 4ebefa084b..99ca446b9b 100644 --- a/src/Management/src/Endpoint/SpringBootAdminClient/SpringBootAdminApiClient.cs +++ b/src/Management/src/Endpoint/SpringBootAdminClient/SpringBootAdminApiClient.cs @@ -28,9 +28,7 @@ public SpringBootAdminApiClient(IHttpClientFactory httpClientFactory) using HttpClient httpClient = CreateHttpClient(options.ConnectionTimeout); string requestUri = $"{options.Url}/instances"; - - HttpResponseMessage response = await httpClient.PostAsJsonAsync(requestUri, application, - SpringBootAdminJsonSerializerContext.OptionsWithReflectionFallback, cancellationToken); + HttpResponseMessage response = await httpClient.PostAsJsonAsync(requestUri, application, cancellationToken); if (!response.IsSuccessStatusCode) { @@ -38,9 +36,7 @@ public SpringBootAdminApiClient(IHttpClientFactory httpClientFactory) throw new HttpRequestException($"Error response from HTTP POST request at {requestUri}: {errorResponse}"); } - RegistrationResult? registrationResult = - await response.Content.ReadFromJsonAsync(SpringBootAdminJsonSerializerContext.Default.RegistrationResult, cancellationToken); - + var registrationResult = await response.Content.ReadFromJsonAsync(cancellationToken); return registrationResult?.Id; } diff --git a/src/Management/src/Endpoint/SpringBootAdminClient/SpringBootAdminJsonSerializerContext.cs b/src/Management/src/Endpoint/SpringBootAdminClient/SpringBootAdminJsonSerializerContext.cs deleted file mode 100644 index 5e862565b5..0000000000 --- a/src/Management/src/Endpoint/SpringBootAdminClient/SpringBootAdminJsonSerializerContext.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the Apache 2.0 License. -// See the LICENSE file in the project root for more information. - -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.Json.Serialization.Metadata; - -namespace Steeltoe.Management.Endpoint.SpringBootAdminClient; - -[JsonSourceGenerationOptions] -[JsonSerializable(typeof(Application))] -[JsonSerializable(typeof(SpringBootAdminApiClient.RegistrationResult))] -internal sealed partial class SpringBootAdminJsonSerializerContext : JsonSerializerContext -{ - private static readonly Lazy LazyOptionsWithReflectionFallback = new(CreateOptionsWithReflectionFallback); - - // Application.Metadata is IDictionary, whose values can be arbitrary types (e.g. DateTime). - // The source generator can't know these types at compile time, so we fall back to reflection for them. - internal static JsonSerializerOptions OptionsWithReflectionFallback => LazyOptionsWithReflectionFallback.Value; - - private static JsonSerializerOptions CreateOptionsWithReflectionFallback() - { - var options = new JsonSerializerOptions(); - options.TypeInfoResolverChain.Add(Default); - options.TypeInfoResolverChain.Add(new DefaultJsonTypeInfoResolver()); - return options; - } -} diff --git a/src/Management/test/Endpoint.Test/SpringBootAdminClient/HostBuilderTest.cs b/src/Management/test/Endpoint.Test/SpringBootAdminClient/HostBuilderTest.cs index 30716b1d34..7687a578c4 100644 --- a/src/Management/test/Endpoint.Test/SpringBootAdminClient/HostBuilderTest.cs +++ b/src/Management/test/Endpoint.Test/SpringBootAdminClient/HostBuilderTest.cs @@ -43,7 +43,7 @@ public async Task CanUseDynamicHttpPort() handler.Mock.Expect(HttpMethod.Post, "http://sba-server.com/instances").With(message => { - requestApplication = message.Content?.ReadFromJsonAsync(SpringBootAdminJsonSerializerContext.Default.Application).GetAwaiter().GetResult(); + requestApplication = message.Content?.ReadFromJsonAsync().GetAwaiter().GetResult(); return true; }).Respond("application/json", """{"Id":"1"}"""); @@ -79,7 +79,7 @@ public async Task CanUseDynamicHttpsPort() handler.Mock.Expect(HttpMethod.Post, "http://sba-server.com/instances").With(message => { - requestApplication = message.Content?.ReadFromJsonAsync(SpringBootAdminJsonSerializerContext.Default.Application).GetAwaiter().GetResult(); + requestApplication = message.Content?.ReadFromJsonAsync().GetAwaiter().GetResult(); return true; }).Respond("application/json", """{"Id":"1"}"""); From 1aea4f62c7f03afc7dd72c6be56086ab47506020 Mon Sep 17 00:00:00 2001 From: Bart Koelman <104792814+bart-vmware@users.noreply.github.com> Date: Tue, 10 Mar 2026 17:16:10 +0100 Subject: [PATCH 18/18] Add test for loggers reset from Apps Manager --- .../Loggers/LoggersEndpointMiddleware.cs | 2 +- .../Actuators/Loggers/LoggersActuatorTest.cs | 106 ++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/Management/src/Endpoint/Actuators/Loggers/LoggersEndpointMiddleware.cs b/src/Management/src/Endpoint/Actuators/Loggers/LoggersEndpointMiddleware.cs index 26cc5dc7a3..aa8b9fc352 100644 --- a/src/Management/src/Endpoint/Actuators/Loggers/LoggersEndpointMiddleware.cs +++ b/src/Management/src/Endpoint/Actuators/Loggers/LoggersEndpointMiddleware.cs @@ -35,7 +35,7 @@ internal sealed partial class LoggersEndpointMiddleware( Dictionary changes = await DeserializeRequestAsync(httpContext.Request.Body, cancellationToken); - // Client sends an empty JSON object to reset the level. + // Spring Boot Admin sends {} to reset the level, while Apps Manager sends {"configuredLevel":null} _ = changes.TryGetValue("configuredLevel", out string? level); LogChangeRequest(loggerName, level ?? "RESET"); diff --git a/src/Management/test/Endpoint.Test/Actuators/Loggers/LoggersActuatorTest.cs b/src/Management/test/Endpoint.Test/Actuators/Loggers/LoggersActuatorTest.cs index 6da4f217e8..bde4c60857 100644 --- a/src/Management/test/Endpoint.Test/Actuators/Loggers/LoggersActuatorTest.cs +++ b/src/Management/test/Endpoint.Test/Actuators/Loggers/LoggersActuatorTest.cs @@ -342,6 +342,112 @@ public async Task Can_change_minimum_levels() """); } + [Fact] + public async Task Can_reset_minimum_level_by_sending_configuredLevel_null() + { + var appSettings = new Dictionary(AppSettings) + { + ["Logging:LogLevel:Default"] = "Error", + ["Logging:LogLevel:Fake"] = "Debug" + }; + + WebApplicationBuilder builder = TestWebApplicationBuilderFactory.Create(); + builder.Configuration.AddInMemoryCollection(appSettings); + EnsureLoggingConfigurationIsBound(builder.Logging, builder.Configuration); + builder.Services.AddSingleton(); + builder.Services.AddLoggersActuator(); + await using WebApplication host = builder.Build(); + + using var loggerFactory = host.Services.GetRequiredService(); + _ = loggerFactory.CreateLogger("Fake.Some"); + + await host.StartAsync(TestContext.Current.CancellationToken); + using HttpClient httpClient = host.GetTestClient(); + + HttpResponseMessage setResponse1 = await httpClient.PostAsync(new Uri("http://localhost/actuator/loggers/Fake"), new StringContent(""" + { + "configuredLevel": "TRACE" + } + """, RequestContentType), TestContext.Current.CancellationToken); + + setResponse1.StatusCode.Should().Be(HttpStatusCode.NoContent); + (await setResponse1.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).Should().BeEmpty(); + + HttpResponseMessage getResponse1 = await httpClient.GetAsync(new Uri("http://localhost/actuator/loggers"), TestContext.Current.CancellationToken); + + getResponse1.StatusCode.Should().Be(HttpStatusCode.OK); + + string getResponseBody1 = await getResponse1.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + getResponseBody1.Should().BeJson(""" + { + "levels": [ + "OFF", + "FATAL", + "ERROR", + "WARN", + "INFO", + "DEBUG", + "TRACE" + ], + "loggers": { + "Default": { + "effectiveLevel": "ERROR" + }, + "Fake": { + "configuredLevel": "DEBUG", + "effectiveLevel": "TRACE" + }, + "Fake.Some": { + "effectiveLevel": "TRACE" + } + }, + "groups": {} + } + """); + + HttpResponseMessage resetResponse = await httpClient.PostAsync(new Uri("http://localhost/actuator/loggers/Fake"), new StringContent(""" + { + "configuredLevel": null + } + """, RequestContentType), TestContext.Current.CancellationToken); + + resetResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + (await resetResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)).Should().BeEmpty(); + + HttpResponseMessage getResponse2 = await httpClient.GetAsync(new Uri("http://localhost/actuator/loggers"), TestContext.Current.CancellationToken); + + getResponse2.StatusCode.Should().Be(HttpStatusCode.OK); + + string getResponseBody2 = await getResponse2.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + getResponseBody2.Should().BeJson(""" + { + "levels": [ + "OFF", + "FATAL", + "ERROR", + "WARN", + "INFO", + "DEBUG", + "TRACE" + ], + "loggers": { + "Default": { + "effectiveLevel": "ERROR" + }, + "Fake": { + "effectiveLevel": "DEBUG" + }, + "Fake.Some": { + "effectiveLevel": "DEBUG" + } + }, + "groups": {} + } + """); + } + [Fact] public async Task Fails_on_invalid_request_body() {