Browse Source

Add Accept header and JSON string unwrap to ClientProxyBase

Refs abpframework/abp#23732
pull/25639/head
maliming 3 months ago
parent
commit
5fb3b26f5c
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 87
      framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs
  2. 192
      framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase_ContentTypes_Tests.cs

87
framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs

@ -108,8 +108,7 @@ public class ClientProxyBase<TService> : 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<TService> : 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<TService> : 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<string>(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<HttpContent> 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<TService> : 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<TService> : 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] == '"')

192
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<string>? contentTypes)
{
return new ActionApiDescriptionModel
{
UniqueName = "Sample",
Name = "Sample",
HttpMethod = "GET",
Url = "api/test",
SupportedVersions = new List<string>(),
ParametersOnMethod = new List<MethodParameterApiDescriptionModel>(),
Parameters = new List<ParameterApiDescriptionModel>(),
ReturnValue = new ReturnValueApiDescriptionModel
{
Type = returnType,
TypeSimple = returnType,
ContentTypes = contentTypes
},
AuthorizeDatas = new List<AuthorizeDataApiDescriptionModel>()
};
}
private sealed class TestableClientProxy : ClientProxyBase<object>
{
public string? PublicGetAcceptForActionReturn(ActionApiDescriptionModel action)
=> GetAcceptForActionReturn(action);
}
}
Loading…
Cancel
Save