From 5fb3b26f5c8e71970af44b49fea3f06e3420e4da Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 14:25:42 +0800 Subject: [PATCH] Add Accept header and JSON string unwrap to ClientProxyBase Refs abpframework/abp#23732 --- .../Client/ClientProxying/ClientProxyBase.cs | 87 +++++++- .../ClientProxyBase_ContentTypes_Tests.cs | 192 ++++++++++++++++++ 2 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase_ContentTypes_Tests.cs diff --git a/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs b/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs index 107008deea..e3d4a06dfb 100644 --- a/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs +++ b/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs @@ -108,8 +108,7 @@ public class ClientProxyBase : ITransientDependency { var responseContent = await RequestAsync(requestContext); - if (typeof(T) == typeof(IRemoteStreamContent) || - typeof(T) == typeof(RemoteStreamContent)) + if (typeof(T).IsAssignableFrom(typeof(RemoteStreamContent))) { /* returning a class that holds a reference to response * content just to be sure that GC does not dispose of @@ -127,7 +126,8 @@ public class ClientProxyBase : ITransientDependency var stringContent = await responseContent.ReadAsStringAsync(); if (typeof(T) == typeof(string)) { - return (T)(object)stringContent; + var unwrapped = UnwrapStringResponse(stringContent, responseContent.Headers?.ContentType?.MediaType); + return (T)(object)unwrapped!; } if (stringContent.IsNullOrWhiteSpace()) @@ -139,6 +139,40 @@ public class ClientProxyBase : ITransientDependency } } + protected virtual string? UnwrapStringResponse(string body, string? contentType) + { + if (body.IsNullOrEmpty() || contentType.IsNullOrWhiteSpace()) + { + return body; + } + + if (!IsJsonMediaType(NormalizeMediaType(contentType!))) + { + return body; + } + + try + { + // JSON null literal deserializes to null — preserve that as empty string for callers expecting non-null. + var parsed = JsonSerializer.Deserialize(body); + return parsed ?? string.Empty; + } + catch + { + return body; + } + } + + protected static string NormalizeMediaType(string mediaType) + { + if (mediaType.IsNullOrWhiteSpace()) + { + return string.Empty; + } + var semi = mediaType.IndexOf(';'); + return (semi < 0 ? mediaType : mediaType.Substring(0, semi)).Trim(); + } + protected virtual async Task RequestAsync(ClientProxyRequestContext requestContext) { var clientConfig = ClientOptions.Value.HttpClientProxies.GetOrDefault(requestContext.ServiceType) ?? throw new AbpException($"Could not get HttpClientProxyConfig for {requestContext.ServiceType.FullName}."); @@ -335,6 +369,16 @@ public class ClientProxyBase : ITransientDependency requestMessage.Headers.Add("api-version", apiVersion.Version); } + //Return-type-aware Accept header (only when none already set) + if (!requestMessage.Headers.Contains("accept")) + { + var acceptForReturn = GetAcceptForActionReturn(action); + if (!acceptForReturn.IsNullOrEmpty()) + { + requestMessage.Headers.Add("accept", acceptForReturn); + } + } + //Header parameters var headers = action.Parameters.Where(p => p.BindingSourceId == ParameterBindingSources.Header).ToArray(); foreach (var headerParameter in headers) @@ -378,6 +422,43 @@ public class ClientProxyBase : ITransientDependency } } + protected virtual string? GetAcceptForActionReturn(ActionApiDescriptionModel action) + { + if (action.ReturnValue.IsRemoteStream || + action.ReturnValue.Type == typeof(IRemoteStreamContent).FullName || + action.ReturnValue.Type == typeof(RemoteStreamContent).FullName) + { + return MimeTypes.Application.OctetStream; + } + + var contentTypes = action.ReturnValue.ContentTypes; + if (contentTypes == null || contentTypes.Count == 0) + { + return null; + } + + var normalized = contentTypes.Select(NormalizeMediaType).ToList(); + + if (normalized.Any(IsJsonMediaType)) + { + return MimeTypes.Application.Json; + } + + if (normalized.All(ct => ct.StartsWith("text/", StringComparison.OrdinalIgnoreCase))) + { + return MimeTypes.Text.Plain; + } + + return null; + } + + private static bool IsJsonMediaType(string normalizedMediaType) + { + return normalizedMediaType.Equals(MimeTypes.Application.Json, StringComparison.OrdinalIgnoreCase) || + normalizedMediaType.Equals("text/json", StringComparison.OrdinalIgnoreCase) || + normalizedMediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase); + } + protected virtual StringSegment RemoveQuotes(StringSegment input) { if (!StringSegment.IsNullOrEmpty(input) && input.Length >= 2 && input[0] == '"' && input[input.Length - 1] == '"') diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase_ContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase_ContentTypes_Tests.cs new file mode 100644 index 0000000000..b2acdfedbb --- /dev/null +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase_ContentTypes_Tests.cs @@ -0,0 +1,192 @@ +#nullable enable +using System.Collections.Generic; +using Shouldly; +using Volo.Abp.Content; +using Volo.Abp.Http.Modeling; +using Xunit; + +namespace Volo.Abp.Http.Client.ClientProxying; + +public class ClientProxyBase_GetAcceptForActionReturn_Tests +{ + [Fact] + public void IRemoteStreamContent_Should_Pick_OctetStream_Even_When_ContentTypes_Include_Json() + { + var action = BuildAction( + returnType: typeof(IRemoteStreamContent).FullName!, + contentTypes: new[] { "application/json", "text/plain", "text/json" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream"); + } + + [Fact] + public void RemoteStreamContent_Concrete_Type_Should_Pick_OctetStream() + { + var action = BuildAction( + returnType: typeof(RemoteStreamContent).FullName!, + contentTypes: null); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream"); + } + + [Fact] + public void Json_In_ContentTypes_Should_Pick_Json() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "text/plain", "application/json", "text/json" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); + } + + [Fact] + public void Only_Text_ContentTypes_Should_Pick_TextPlain() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "text/plain", "text/csv" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain"); + } + + [Fact] + public void Empty_Or_Null_ContentTypes_Should_Return_Null() + { + InvokeGetAcceptForActionReturn(BuildAction("System.Int32", null)).ShouldBeNull(); + InvokeGetAcceptForActionReturn(BuildAction("System.Int32", new string[0])).ShouldBeNull(); + } + + [Fact] + public void Mixed_Text_And_Octet_Stream_Should_Not_Pick_TextPlain() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "text/plain", "application/octet-stream" }); + + InvokeGetAcceptForActionReturn(action).ShouldBeNull(); + } + + [Fact] + public void JsonV2_Variant_Should_Still_Pick_Json() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "application/json; charset=utf-8" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); + } + + [Fact] + public void Single_TextHtml_Should_Pick_TextPlain() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "text/html" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain"); + } + + [Fact] + public void OctetStream_Only_With_ObjectReturn_Should_Return_Null() + { + var action = BuildAction( + returnType: "My.Project.UserDto", + contentTypes: new[] { "application/octet-stream" }); + + InvokeGetAcceptForActionReturn(action).ShouldBeNull(); + } + + [Fact] + public void Case_Insensitive_Json_Match() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "APPLICATION/JSON" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); + } + + [Fact] + public void Json_With_Charset_Parameter_Should_Still_Pick_Json() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "application/json; charset=utf-8" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); + } + + [Fact] + public void Text_With_Charset_Parameter_Should_Still_Pick_TextPlain() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "text/plain ; charset=utf-8 " }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain"); + } + + [Fact] + public void Text_Json_Should_Be_Treated_As_Json() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "text/json" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); + } + + [Fact] + public void Application_Problem_Json_Should_Be_Treated_As_Json() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "application/problem+json" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); + } + + [Fact] + public void IsRemoteStream_Flag_True_Should_Pick_OctetStream_Regardless_Of_TypeName() + { + var action = BuildAction( + returnType: "My.Project.CustomStream", + contentTypes: new[] { "application/json" }); + action.ReturnValue.IsRemoteStream = true; + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream"); + } + + private static string? InvokeGetAcceptForActionReturn(ActionApiDescriptionModel action) + { + var proxy = new TestableClientProxy(); + return proxy.PublicGetAcceptForActionReturn(action); + } + + private static ActionApiDescriptionModel BuildAction(string returnType, IList? contentTypes) + { + return new ActionApiDescriptionModel + { + UniqueName = "Sample", + Name = "Sample", + HttpMethod = "GET", + Url = "api/test", + SupportedVersions = new List(), + ParametersOnMethod = new List(), + Parameters = new List(), + ReturnValue = new ReturnValueApiDescriptionModel + { + Type = returnType, + TypeSimple = returnType, + ContentTypes = contentTypes + }, + AuthorizeDatas = new List() + }; + } + + private sealed class TestableClientProxy : ClientProxyBase + { + public string? PublicGetAcceptForActionReturn(ActionApiDescriptionModel action) + => GetAcceptForActionReturn(action); + } +}