From 89772d24335ddc754d1e4b4f81c0c185015aa755 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 14:24:04 +0800 Subject: [PATCH] Add ContentTypes and IsRemoteStream to ReturnValueApiDescriptionModel Refs abpframework/abp#23732 --- .../Modeling/ActionApiDescriptionModel.cs | 4 +- .../ReturnValueApiDescriptionModel.cs | 38 ++- .../ReturnValueApiDescriptionModel_Tests.cs | 285 ++++++++++++++++++ 3 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel_Tests.cs diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs index 7650e40f88..e01b39c94d 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs @@ -45,7 +45,7 @@ public class ActionApiDescriptionModel } - public static ActionApiDescriptionModel Create([NotNull] string uniqueName, [NotNull] MethodInfo method, [NotNull] string url, string? httpMethod, [NotNull] IList supportedVersions, bool? allowAnonymous = null, IList? authorizeDatas = null, string? implementFrom = null) + public static ActionApiDescriptionModel Create([NotNull] string uniqueName, [NotNull] MethodInfo method, [NotNull] string url, string? httpMethod, [NotNull] IList supportedVersions, bool? allowAnonymous = null, IList? authorizeDatas = null, string? implementFrom = null, IList? returnValueContentTypes = null) { Check.NotNull(uniqueName, nameof(uniqueName)); Check.NotNull(method, nameof(method)); @@ -58,7 +58,7 @@ public class ActionApiDescriptionModel Name = method.Name, Url = url, HttpMethod = httpMethod, - ReturnValue = ReturnValueApiDescriptionModel.Create(method.ReturnType), + ReturnValue = ReturnValueApiDescriptionModel.Create(method.ReturnType, returnValueContentTypes), Parameters = new List(), ParametersOnMethod = method .GetParameters() diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs index e77d2f7fea..dcd1b16e6b 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs @@ -1,4 +1,7 @@ using System; +using System.Collections; +using System.Collections.Generic; +using Volo.Abp.Content; using Volo.Abp.Reflection; using Volo.Abp.Threading; @@ -13,19 +16,50 @@ public class ReturnValueApiDescriptionModel public string? Summary { get; set; } + public IList? ContentTypes { get; set; } + + public bool IsRemoteStream { get; set; } + public ReturnValueApiDescriptionModel() { } - public static ReturnValueApiDescriptionModel Create(Type type) + public static ReturnValueApiDescriptionModel Create(Type type, IList? contentTypes = null) { var unwrappedType = AsyncHelper.UnwrapTask(type); return new ReturnValueApiDescriptionModel { Type = TypeHelper.GetFullNameHandlingNullableAndGenerics(unwrappedType), - TypeSimple = ApiTypeNameHelper.GetSimpleTypeName(unwrappedType) + TypeSimple = ApiTypeNameHelper.GetSimpleTypeName(unwrappedType), + ContentTypes = contentTypes, + IsRemoteStream = IsRemoteStreamType(unwrappedType) }; } + + private static bool IsRemoteStreamType(Type type) + { + if (typeof(IRemoteStreamContent).IsAssignableFrom(type)) + { + return true; + } + + if (type.IsArray && type.GetElementType() is { } elementType && + typeof(IRemoteStreamContent).IsAssignableFrom(elementType)) + { + return true; + } + + if (typeof(IEnumerable).IsAssignableFrom(type) && type.IsGenericType) + { + var genericArg = type.GetGenericArguments()[0]; + if (typeof(IRemoteStreamContent).IsAssignableFrom(genericArg)) + { + return true; + } + } + + return false; + } } diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel_Tests.cs new file mode 100644 index 0000000000..c95f5d9be3 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel_Tests.cs @@ -0,0 +1,285 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.Content; +using Volo.Abp.Http.Modeling; +using Xunit; + +namespace Volo.Abp.Http.Modeling; + +public class ReturnValueApiDescriptionModel_Tests +{ + [Fact] + public void Create_Without_ContentTypes_Should_Leave_Property_Null() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(string)); + + model.ShouldNotBeNull(); + model.TypeSimple.ShouldBe("string"); + model.ContentTypes.ShouldBeNull(); + } + + [Fact] + public void Create_With_ContentTypes_Should_Populate_The_Property() + { + var model = ReturnValueApiDescriptionModel.Create( + typeof(string), + new[] { "application/json", "text/plain" }); + + model.ContentTypes.ShouldNotBeNull(); + model.ContentTypes!.ShouldBe(new[] { "application/json", "text/plain" }); + } +} + +public class ReturnValueApiDescriptionModel_IsRemoteStream_Tests +{ + [Fact] + public void Direct_IRemoteStreamContent_Should_Be_True() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(IRemoteStreamContent)); + model.IsRemoteStream.ShouldBeTrue(); + } + + [Fact] + public void Concrete_RemoteStreamContent_Should_Be_True() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(RemoteStreamContent)); + model.IsRemoteStream.ShouldBeTrue(); + } + + [Fact] + public void Custom_Subclass_Of_IRemoteStreamContent_Should_Be_True() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(MyCustomStreamContent)); + model.IsRemoteStream.ShouldBeTrue(); + } + + [Fact] + public void Task_Of_IRemoteStreamContent_Should_Be_True_After_UnwrapTask() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(Task)); + model.IsRemoteStream.ShouldBeTrue(); + } + + [Fact] + public void Task_Of_Custom_Stream_Subclass_Should_Be_True_After_UnwrapTask() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(Task)); + model.IsRemoteStream.ShouldBeTrue(); + } + + [Fact] + public void IRemoteStreamContent_Array_Should_Be_True() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(IRemoteStreamContent[])); + model.IsRemoteStream.ShouldBeTrue(); + } + + [Fact] + public void Concrete_RemoteStreamContent_Array_Should_Be_True() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(RemoteStreamContent[])); + model.IsRemoteStream.ShouldBeTrue(); + } + + [Fact] + public void List_Of_IRemoteStreamContent_Should_Be_True() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(List)); + model.IsRemoteStream.ShouldBeTrue(); + } + + [Fact] + public void IEnumerable_Of_IRemoteStreamContent_Should_Be_True() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(IEnumerable)); + model.IsRemoteStream.ShouldBeTrue(); + } + + [Fact] + public void IReadOnlyCollection_Of_IRemoteStreamContent_Should_Be_True() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(IReadOnlyCollection)); + model.IsRemoteStream.ShouldBeTrue(); + } + + [Fact] + public void Plain_String_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(string)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void Int_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(int)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void Plain_Dto_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(PlainDto)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void Dto_Containing_IRemoteStreamContent_Property_Should_Be_False() + { + // ABP design constraint: IRemoteStreamContent only works as the DIRECT endpoint return type. + // When nested inside a DTO, the server JSON-serializes the DTO and the stream metadata only — + // the binary payload is lost. So the proxy should NOT use blob mode for this case. + var model = ReturnValueApiDescriptionModel.Create(typeof(DtoWithStream)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void Dto_Inheriting_From_Type_With_Stream_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(DtoInheritingStream)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void Byte_Array_Should_Be_False() + { + // byte[] is serialized as base64 JSON by ABP, not as binary stream. + var model = ReturnValueApiDescriptionModel.Create(typeof(byte[])); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void Dictionary_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(Dictionary)); + model.IsRemoteStream.ShouldBeFalse(); + } + + private class MyCustomStreamContent : IRemoteStreamContent + { + public string? FileName => null; + public string? ContentType => null; + public long? ContentLength => null; + public Stream GetStream() => Stream.Null; + public void Dispose() { } + } + + private class PlainDto + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + private class DtoWithStream + { + public string FileName { get; set; } = string.Empty; + public IRemoteStreamContent? File { get; set; } + } + + private class DtoInheritingStream : PlainDto + { + public IRemoteStreamContent? Stream { get; set; } + } +} + +public class ReturnValueApiDescriptionModel_BackwardsCompat_Tests +{ + [Fact] + public void Deserializing_Json_Without_ContentTypes_Field_Should_Leave_It_Null() + { + // Old backend JSON (no contentTypes field) read by new client. + var json = """ + { + "type": "System.String", + "typeSimple": "string" + } + """; + + var model = System.Text.Json.JsonSerializer.Deserialize( + json, + new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + model.ShouldNotBeNull(); + model!.Type.ShouldBe("System.String"); + model.TypeSimple.ShouldBe("string"); + model.ContentTypes.ShouldBeNull(); + } + + [Fact] + public void Deserializing_Json_With_ContentTypes_Field_Should_Populate_It() + { + var json = """ + { + "type": "System.String", + "typeSimple": "string", + "contentTypes": ["application/json", "text/plain"] + } + """; + + var model = System.Text.Json.JsonSerializer.Deserialize( + json, + new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + model!.ContentTypes.ShouldNotBeNull(); + model.ContentTypes!.ShouldBe(new[] { "application/json", "text/plain" }); + } + + [Fact] + public void Serializing_With_Null_ContentTypes_Should_Emit_Null_Or_Omit() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(string)); + var json = System.Text.Json.JsonSerializer.Serialize(model); + + // Either "contentTypes":null or omitted — both are acceptable for old clients. + var deserialized = System.Text.Json.JsonSerializer.Deserialize( + json, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + deserialized!.ContentTypes.ShouldBeNull(); + } +} + +public class ActionApiDescriptionModel_Tests +{ + [Fact] + public void Create_Should_Propagate_ReturnValueContentTypes() + { + var method = typeof(ActionApiDescriptionModel_Tests).GetMethod(nameof(SampleMethod))!; + var model = ActionApiDescriptionModel.Create( + uniqueName: "SampleMethod", + method: method, + url: "api/test/sample", + httpMethod: "GET", + supportedVersions: new[] { "1.0" }, + allowAnonymous: true, + authorizeDatas: null, + implementFrom: null, + returnValueContentTypes: new[] { "application/octet-stream" }); + + model.ReturnValue.ContentTypes.ShouldNotBeNull(); + model.ReturnValue.ContentTypes!.ShouldBe(new[] { "application/octet-stream" }); + } + + [Fact] + public void Create_Without_ReturnValueContentTypes_Should_Leave_Null() + { + var method = typeof(ActionApiDescriptionModel_Tests).GetMethod(nameof(SampleMethod))!; + var model = ActionApiDescriptionModel.Create( + uniqueName: "SampleMethod", + method: method, + url: "api/test/sample", + httpMethod: "GET", + supportedVersions: new[] { "1.0" }); + + model.ReturnValue.ContentTypes.ShouldBeNull(); + } + + public string SampleMethod() => string.Empty; +}