diff --git a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs index f1821522..324654a8 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway/ExternalSystemClient.cs @@ -614,7 +614,7 @@ public class ExternalSystemClient : IExternalSystemClient var consumed = consumedParams; var queryString = string.Join("&", parameters.Where(p => p.Value != null && !consumed.Contains(p.Key)) - .Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value?.ToString() ?? "")}")); + .Select(p => $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(FormatQueryValue(p.Value))}")); // Only append "?" when the effective query string is non-empty — a method // whose parameter values are all null produces no query string, and the @@ -629,6 +629,31 @@ public class ExternalSystemClient : IExternalSystemClient return url; } + /// + /// Renders a GET/DELETE query-string value. Scalars use ToString(); collections, + /// dictionaries and other complex objects are JSON-serialised (as the POST/PUT/PATCH + /// branch already does) instead of degrading to a CLR type name such as + /// System.Int32[] (review 2026-10-21 05/O2). + /// + internal static string FormatQueryValue(object? value) + { + switch (value) + { + case null: + return string.Empty; + case string or char or bool or Guid or Uri or DateTime or DateTimeOffset or TimeSpan or Enum or decimal: + return value.ToString() ?? string.Empty; + case JsonElement { ValueKind: JsonValueKind.Object or JsonValueKind.Array } json: + return json.GetRawText(); + case JsonElement scalar: + return scalar.ToString(); + } + + return value.GetType().IsPrimitive + ? value.ToString() ?? string.Empty + : JsonSerializer.Serialize(value); + } + private void ApplyAuth(HttpRequestMessage request, ExternalSystemDefinition system) { // Distinguish "intentionally unauthenticated" (AuthType = none) diff --git a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs index f01c93fb..f9f48ea5 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ExternalSystemGateway.Tests/ExternalSystemClientTests.cs @@ -782,6 +782,35 @@ public class ExternalSystemClientTests : IDisposable Assert.Contains("page=2", uri); } + /// Verifies that non-scalar GET parameters are JSON-serialised rather than rendered as CLR type names. + /// A task that represents the asynchronous operation. + [Fact] + public async Task Call_GetWithNonScalarParameters_JsonSerialisesThem() + { + // Review 2026-10-21 05/O2: new[]{1,2,3}.ToString() is "System.Int32[]". + var system = new ExternalSystemDefinition("TestAPI", "https://api.example.com", "none") { Id = 1 }; + var method = new ExternalSystemMethod("search", "GET", "/search") { Id = 1, ExternalSystemDefinitionId = 1 }; + StubResolution(system, method); + + var handler = new RequestCapturingHandler(HttpStatusCode.OK, "{}"); + _httpClientFactory.CreateClient(Arg.Any()).Returns(new HttpClient(handler)); + var client = new ExternalSystemClient( + _httpClientFactory, _repository, NullLogger.Instance); + + await client.CallAsync("TestAPI", "search", new Dictionary + { + ["ids"] = new[] { 1, 2, 3 }, + ["f"] = new Dictionary { ["a"] = 1 }, + ["n"] = 7, + }); + + var uri = handler.LastUri!.AbsoluteUri; + Assert.Contains("ids=" + Uri.EscapeDataString("[1,2,3]"), uri); + Assert.Contains("f=" + Uri.EscapeDataString("{\"a\":1}"), uri); + Assert.Contains("n=7", uri); + Assert.DoesNotContain("System.", uri); + } + /// Verifies that a GET call whose parameters are all null does not append a trailing question mark. /// A task that represents the asynchronous operation. [Fact]