From 89772d24335ddc754d1e4b4f81c0c185015aa755 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 14:24:04 +0800 Subject: [PATCH 01/16] 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; +} From 6844e6d7319e244ec49db2630c0ae09db74cdada Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 14:24:34 +0800 Subject: [PATCH 02/16] Extract response ContentTypes from ApiDescription in MVC provider Refs abpframework/abp#23732 --- .../AspNetCoreApiDescriptionModelProvider.cs | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs index 464981cb3c..bd5e1e82a0 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs @@ -175,7 +175,8 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide GetSupportedVersions(controllerType, method, setting), allowAnonymous, authorizeModels, - implementFrom + implementFrom, + GetReturnValueContentTypes(apiDescription) ) ); @@ -199,6 +200,27 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide } } + private static List? GetReturnValueContentTypes(ApiDescription apiDescription) + { + var preferred = apiDescription.SupportedResponseTypes + .FirstOrDefault(x => x.StatusCode == 200 && x.ApiResponseFormats.Any()) + ?? apiDescription.SupportedResponseTypes + .FirstOrDefault(x => x.StatusCode is >= 200 and < 300 && x.ApiResponseFormats.Any()); + + if (preferred == null) + { + return null; + } + + var contentTypes = preferred.ApiResponseFormats + .Select(f => f.MediaType) + .Where(m => !string.IsNullOrWhiteSpace(m)) + .Distinct() + .ToList(); + + return contentTypes.Count > 0 ? contentTypes : null; + } + private static List GetSupportedVersions(Type controllerType, MethodInfo method, ConventionalControllerSetting? setting) { From a8d93a245d7703ff38c0b65f4c5c346bce5a2545 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 14:25:05 +0800 Subject: [PATCH 03/16] Use ContentTypes for dataType and Accept in jQuery proxy generator Refs abpframework/abp#23732 --- .../JQuery/JQueryProxyScriptGenerator.cs | 44 +++++- ...ProxyScriptGenerator_ContentTypes_Tests.cs | 129 ++++++++++++++++++ 2 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs index 18af3e0661..ad44a98316 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs @@ -136,9 +136,7 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen AddAjaxCallParameters(script, action); var ajaxParamsIsFromForm = action.Parameters.Any(x => x.BindingSourceId == ParameterBindingSources.Form); - var dataType = action.ReturnValue.Type == ReturnValueApiDescriptionModel.Create(typeof(string)).Type - ? "{ dataType: 'text' }, " - : string.Empty; + var dataType = GetJQueryDataTypeAndAcceptOverride(action); script.AppendLine(ajaxParamsIsFromForm ? " }, $.extend(true, {}, " + dataType + "{ contentType: 'application/x-www-form-urlencoded; charset=UTF-8' }, ajaxParams)));" : " }, " + dataType + "ajaxParams));"); @@ -146,6 +144,46 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen script.AppendLine(" };"); } + private static string GetJQueryDataTypeAndAcceptOverride(ActionApiDescriptionModel action) + { + var contentTypes = action.ReturnValue.ContentTypes; + var isStringReturn = action.ReturnValue.Type == ReturnValueApiDescriptionModel.Create(typeof(string)).Type; + + if (contentTypes is { Count: > 0 }) + { + var normalized = contentTypes.Select(NormalizeMediaType).ToList(); + + if (normalized.Any(IsJsonMediaType)) + { + return "{ dataType: 'json', headers: { Accept: 'application/json' } }, "; + } + + if (normalized.All(ct => ct.StartsWith("text/", StringComparison.OrdinalIgnoreCase))) + { + return "{ dataType: 'text', headers: { Accept: 'text/plain' } }, "; + } + } + + return isStringReturn ? "{ dataType: 'text' }, " : string.Empty; + } + + private static bool IsJsonMediaType(string normalizedMediaType) + { + return normalizedMediaType.Equals("application/json", StringComparison.OrdinalIgnoreCase) || + normalizedMediaType.Equals("text/json", StringComparison.OrdinalIgnoreCase) || + normalizedMediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeMediaType(string mediaType) + { + if (string.IsNullOrWhiteSpace(mediaType)) + { + return string.Empty; + } + var semi = mediaType.IndexOf(';'); + return (semi < 0 ? mediaType : mediaType.Substring(0, semi)).Trim(); + } + private static string FindBestApiVersion(ActionApiDescriptionModel action) { //var configuredVersion = GetConfiguredApiVersion(); //TODO: Implement diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs new file mode 100644 index 0000000000..ee0a20114c --- /dev/null +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs @@ -0,0 +1,129 @@ +#nullable enable +using System.Collections.Generic; +using Microsoft.Extensions.Options; +using Shouldly; +using Volo.Abp.Http.Modeling; +using Volo.Abp.Http.ProxyScripting.Generators; +using Volo.Abp.Http.ProxyScripting.Generators.JQuery; +using Xunit; + +namespace Volo.Abp.Http.ProxyScripting.Generators.JQuery; + +public class JQueryProxyScriptGenerator_ContentTypes_Tests +{ + private readonly JQueryProxyScriptGenerator _generator = new( + Microsoft.Extensions.Options.Options.Create(new DynamicJavaScriptProxyOptions())); + + [Fact] + public void Should_Emit_Json_DataType_And_Accept_When_ContentTypes_Contain_Json() + { + var script = _generator.CreateScript(BuildAppModel( + returnType: "System.String", + contentTypes: new[] { "text/plain", "application/json", "text/json" })); + + script.ShouldContain("dataType: 'json'"); + script.ShouldContain("Accept: 'application/json'"); + script.ShouldNotContain("dataType: 'text'"); + } + + [Fact] + public void Should_Emit_Text_DataType_And_Accept_When_ContentTypes_Only_Text() + { + var script = _generator.CreateScript(BuildAppModel( + returnType: "System.String", + contentTypes: new[] { "text/plain", "text/csv" })); + + script.ShouldContain("dataType: 'text'"); + script.ShouldContain("Accept: 'text/plain'"); + } + + [Fact] + public void Should_Fallback_To_Legacy_Text_When_Return_Is_String_And_ContentTypes_Null() + { + var script = _generator.CreateScript(BuildAppModel( + returnType: "System.String", + contentTypes: null)); + + // Legacy behavior preserved: string return → dataType: 'text' (no Accept override) + script.ShouldContain("dataType: 'text'"); + script.ShouldNotContain("Accept:"); + } + + [Fact] + public void Should_Emit_No_DataType_For_Non_String_Return_Without_ContentTypes() + { + var script = _generator.CreateScript(BuildAppModel( + returnType: "System.Int32", + contentTypes: null)); + + script.ShouldNotContain("dataType: 'text'"); + script.ShouldNotContain("dataType: 'json'"); + } + + [Fact] + public void Should_Not_Emit_Json_DataType_When_Only_Binary_ContentTypes() + { + var script = _generator.CreateScript(BuildAppModel( + returnType: "System.Byte[]", + contentTypes: new[] { "application/octet-stream", "image/png" })); + + // jQuery dataType doesn't have a clean "blob" — fall through to no override + script.ShouldNotContain("dataType: 'json'"); + script.ShouldNotContain("dataType: 'text'"); + } + + [Fact] + public void Should_Prefer_Json_When_Json_Present_Even_With_Other_Types() + { + var script = _generator.CreateScript(BuildAppModel( + returnType: "My.Project.UserDto", + contentTypes: new[] { "application/xml", "application/json", "text/html" })); + + script.ShouldContain("dataType: 'json'"); + script.ShouldContain("Accept: 'application/json'"); + } + + [Fact] + public void Case_Insensitive_Json_Detection() + { + var script = _generator.CreateScript(BuildAppModel( + returnType: "System.String", + contentTypes: new[] { "APPLICATION/JSON" })); + + script.ShouldContain("dataType: 'json'"); + } + + private static ApplicationApiDescriptionModel BuildAppModel(string returnType, IList? contentTypes) + { + var model = ApplicationApiDescriptionModel.Create(); + var module = model.GetOrAddModule("app", "Default"); + var controller = module.GetOrAddController( + name: "TestController", + groupName: null, + isRemoteService: true, + isIntegrationService: false, + apiVersion: null, + type: typeof(object)); + + var action = new ActionApiDescriptionModel + { + UniqueName = "DoSomethingAsync", + Name = "DoSomethingAsync", + HttpMethod = "GET", + Url = "api/test/do-something", + SupportedVersions = new List(), + ParametersOnMethod = new List(), + Parameters = new List(), + ReturnValue = new ReturnValueApiDescriptionModel + { + Type = returnType, + TypeSimple = returnType, + ContentTypes = contentTypes, + }, + AuthorizeDatas = new List(), + }; + controller.AddAction("DoSomethingAsync", action); + + return model; + } +} From 5fb3b26f5c8e71970af44b49fea3f06e3420e4da Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 14:25:42 +0800 Subject: [PATCH 04/16] 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); + } +} From a765cc036cc473fab14f6d59f01bf5d9cb799172 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 14:26:23 +0800 Subject: [PATCH 05/16] Add end-to-end ClientProxy tests for ContentTypes scenarios Refs abpframework/abp#23732 --- .../DynamicProxying/IRegularTestController.cs | 19 ++++ ...iceClientProxy_ReturnContentTypes_Tests.cs | 84 +++++++++++++++ .../DynamicProxying/RegularTestController.cs | 72 +++++++++++++ ...lerClientProxy_ReturnContentTypes_Tests.cs | 102 ++++++++++++++++++ .../TestApp/Application/IPeopleAppService.cs | 8 ++ .../TestApp/Application/PeopleAppService.cs | 25 +++++ 6 files changed, 310 insertions(+) create mode 100644 framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/PeopleAppServiceClientProxy_ReturnContentTypes_Tests.cs create mode 100644 framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_ReturnContentTypes_Tests.cs diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs index 21786155bf..5be89375a0 100644 --- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs @@ -1,6 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Volo.Abp.Content; namespace Volo.Abp.Http.DynamicProxying; @@ -8,6 +9,24 @@ public interface IRegularTestController { Task IncrementValueAsync(int value); + Task GetPlainStringAsync(); + + Task GetProducesJsonStringAsync(); + + Task GetProducesTextStringAsync(); + + Task GetNullStringAsync(); + + Task GetProducesJsonNullStringAsync(); + + Task GetEmptyStringAsync(); + + Task GetEscapedStringAsync(); + + Task DownloadIconAsync(); + + Task GetByteArrayAsync(); + Task GetException1Async(); Task GetException2Async(); diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/PeopleAppServiceClientProxy_ReturnContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/PeopleAppServiceClientProxy_ReturnContentTypes_Tests.cs new file mode 100644 index 0000000000..b89692eb90 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/PeopleAppServiceClientProxy_ReturnContentTypes_Tests.cs @@ -0,0 +1,84 @@ +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Http.Client; +using Volo.Abp.TestApp.Application; +using Xunit; + +namespace Volo.Abp.Http.DynamicProxying; + +public class PeopleAppServiceClientProxy_ReturnContentTypes_Tests : AbpHttpClientTestBase +{ + private readonly IPeopleAppService _peopleAppService; + + public PeopleAppServiceClientProxy_ReturnContentTypes_Tests() + { + _peopleAppService = ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task EchoStatusAsync_Should_Return_Plain_String_Without_Quotes() + { + var status = await _peopleAppService.EchoStatusAsync(); + status.ShouldBe("Open"); + status.StartsWith("\"").ShouldBeFalse(); + status.EndsWith("\"").ShouldBeFalse(); + } + + [Fact] + public async Task EchoStatusWithProducesJsonAsync_Should_Return_Plain_String_Without_Quotes() + { + var status = await _peopleAppService.EchoStatusWithProducesJsonAsync(); + status.ShouldBe("Open"); + status.StartsWith("\"").ShouldBeFalse(); + status.EndsWith("\"").ShouldBeFalse(); + } + + [Fact] + public async Task GetBinaryImageAsync_Should_Return_Real_Binary_Not_Json_Metadata() + { + using var content = await _peopleAppService.GetBinaryImageAsync(); + using var ms = new MemoryStream(); + await content.GetStream().CopyToAsync(ms); + var bytes = ms.ToArray(); + + content.FileName.ShouldBe("tiny.png"); + content.ContentType.ShouldStartWith("image/png"); + bytes.Length.ShouldBeGreaterThan(8); + + bytes[0].ShouldBe((byte)0x89); + bytes[1].ShouldBe((byte)0x50); + bytes[2].ShouldBe((byte)0x4E); + bytes[3].ShouldBe((byte)0x47); + } + + [Fact] + public async Task ThrowFromStringAsync_Should_Surface_Server_Exception_To_Client() + { + await Should.ThrowAsync( + () => _peopleAppService.ThrowFromStringAsync() + ); + } + + [Fact] + public async Task DownloadAsync_Should_Still_Work() + { + using var content = await _peopleAppService.DownloadAsync(); + using var reader = new StreamReader(content.GetStream()); + var text = await reader.ReadToEndAsync(); + text.ShouldBe("DownloadAsync"); + content.FileName.ShouldBe("download.rtf"); + content.ContentType.ShouldStartWith("application/rtf"); + } + + [Fact] + public async Task UploadAsync_String_Return_Should_Stay_Unquoted() + { + using var ms = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("hello")); + var result = await _peopleAppService.UploadAsync( + new Content.RemoteStreamContent(ms, "upload.txt", "text/plain")); + result.ShouldBe("hello:text/plain:upload.txt"); + result.StartsWith("\"").ShouldBeFalse(); + } +} diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs index 46b355090b..437d047b71 100644 --- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs @@ -1,10 +1,13 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.Content; namespace Volo.Abp.Http.DynamicProxying; @@ -20,6 +23,75 @@ public class RegularTestController : AbpController, IRegularTestController return Task.FromResult(value + 1); } + [HttpGet] + [Route("plain-string")] + public Task GetPlainStringAsync() + { + return Task.FromResult("Open"); + } + + [HttpGet] + [Route("produces-json-string")] + [Produces("application/json")] + public Task GetProducesJsonStringAsync() + { + return Task.FromResult("Open"); + } + + [HttpGet] + [Route("produces-text-string")] + [Produces("text/plain")] + public Task GetProducesTextStringAsync() + { + return Task.FromResult("Open"); + } + + [HttpGet] + [Route("null-string")] + public Task GetNullStringAsync() + { + return Task.FromResult(null!); + } + + [HttpGet] + [Route("produces-json-null-string")] + [Produces("application/json")] + public Task GetProducesJsonNullStringAsync() + { + return Task.FromResult(null!); + } + + [HttpGet] + [Route("empty-string")] + public Task GetEmptyStringAsync() + { + return Task.FromResult(string.Empty); + } + + [HttpGet] + [Route("escaped-string")] + [Produces("application/json")] + public Task GetEscapedStringAsync() + { + return Task.FromResult("a\"b\\c\nd"); + } + + [HttpGet] + [Route("download-icon")] + public Task DownloadIconAsync() + { + var bytes = Encoding.UTF8.GetBytes("ICON-BYTES"); + return Task.FromResult( + new RemoteStreamContent(new MemoryStream(bytes), "icon.bin", "application/octet-stream")); + } + + [HttpGet] + [Route("byte-array")] + public Task GetByteArrayAsync() + { + return Task.FromResult(new byte[] { 1, 2, 3, 4 }); + } + [HttpGet] [Route("get-exception1")] public Task GetException1Async() diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_ReturnContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_ReturnContentTypes_Tests.cs new file mode 100644 index 0000000000..a6725d6e6c --- /dev/null +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_ReturnContentTypes_Tests.cs @@ -0,0 +1,102 @@ +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Volo.Abp.Http.DynamicProxying; + +public class RegularTestControllerClientProxy_ReturnContentTypes_Tests : AbpHttpClientTestBase +{ + private readonly IRegularTestController _controller; + + public RegularTestControllerClientProxy_ReturnContentTypes_Tests() + { + _controller = ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task GetPlainStringAsync_Should_Return_Unwrapped_String() + { + var result = await _controller.GetPlainStringAsync(); + result.ShouldBe("Open"); + result.StartsWith("\"").ShouldBeFalse(); + } + + [Fact] + public async Task GetProducesJsonStringAsync_Should_Strip_Json_Quotes() + { + var result = await _controller.GetProducesJsonStringAsync(); + result.ShouldBe("Open"); + result.StartsWith("\"").ShouldBeFalse(); + result.EndsWith("\"").ShouldBeFalse(); + } + + [Fact] + public async Task GetProducesTextStringAsync_Should_Return_Raw_Text_Body() + { + var result = await _controller.GetProducesTextStringAsync(); + result.ShouldBe("Open"); + result.StartsWith("\"").ShouldBeFalse(); + } + + [Fact] + public async Task GetNullStringAsync_Should_Return_Default_Or_Empty() + { + var result = await _controller.GetNullStringAsync(); + (result == null || result == string.Empty).ShouldBeTrue(); + } + + [Fact] + public async Task GetEmptyStringAsync_Should_Return_Empty() + { + var result = await _controller.GetEmptyStringAsync(); + (result == null || result == string.Empty).ShouldBeTrue(); + } + + [Fact] + public async Task GetProducesJsonNullStringAsync_Should_Not_Return_Literal_Null() + { + // Server returns JSON `null` body (4 chars). The unwrap MUST NOT pass through "null" literal — + // it should produce empty/null on the client side instead. + var result = await _controller.GetProducesJsonNullStringAsync(); + result.ShouldNotBe("null"); + (result == null || result == string.Empty).ShouldBeTrue(); + } + + [Fact] + public async Task GetEscapedStringAsync_Should_Decode_Escaped_Characters() + { + // Server JSON-encodes the string with escapes: "a\"b\\c\nd" + // Without unwrap fix client would receive the raw JSON string including escapes. + var result = await _controller.GetEscapedStringAsync(); + result.ShouldBe("a\"b\\c\nd"); + } + + [Fact] + public async Task DownloadIconAsync_Should_Return_Binary_Bytes() + { + using var content = await _controller.DownloadIconAsync(); + using var ms = new MemoryStream(); + await content.GetStream().CopyToAsync(ms); + ms.ToArray().ShouldBe(System.Text.Encoding.UTF8.GetBytes("ICON-BYTES")); + content.FileName.ShouldBe("icon.bin"); + } + + [Fact] + public async Task GetByteArrayAsync_Should_Round_Trip_Bytes() + { + // byte[] is not IRemoteStreamContent; goes through default JSON path + // (server JSON-encodes as base64). Ensures our Accept logic didn't break + // the existing non-stream binary case. + var bytes = await _controller.GetByteArrayAsync(); + bytes.ShouldBe(new byte[] { 1, 2, 3, 4 }); + } + + [Fact] + public async Task Existing_IncrementValueAsync_Regression_Should_Still_Work() + { + var result = await _controller.IncrementValueAsync(41); + result.ShouldBe(42); + } +} diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IPeopleAppService.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IPeopleAppService.cs index a39216add5..24ad49d7fa 100644 --- a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IPeopleAppService.cs +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IPeopleAppService.cs @@ -39,4 +39,12 @@ public interface IPeopleAppService : ICrudAppService Task GetParamsFromQueryAsync(GetParamsInput input); Task GetParamsFromFormAsync(GetParamsInput input); + + Task EchoStatusAsync(); + + Task EchoStatusWithProducesJsonAsync(); + + Task GetBinaryImageAsync(); + + Task ThrowFromStringAsync(); } diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/PeopleAppService.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/PeopleAppService.cs index a3920ec8ca..5eb9370754 100644 --- a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/PeopleAppService.cs +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/PeopleAppService.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Shouldly; +using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.TestApp.Domain; using Volo.Abp.Domain.Repositories; @@ -94,6 +95,30 @@ public class PeopleAppService : CrudAppService, IPeople return new RemoteStreamContent(memoryStream, "download.rtf", "application/rtf"); } + public Task EchoStatusAsync() + { + return Task.FromResult("Open"); + } + + [Produces("application/json")] + public Task EchoStatusWithProducesJsonAsync() + { + return Task.FromResult("Open"); + } + + public Task GetBinaryImageAsync() + { + var bytes = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="); + return Task.FromResult( + new RemoteStreamContent(new MemoryStream(bytes), "tiny.png", "image/png")); + } + + public Task ThrowFromStringAsync() + { + throw new BusinessException("TestApp.StringEndpointBoom", "string endpoint failed"); + } + public async Task UploadAsync(IRemoteStreamContent streamContent) { using (var reader = new StreamReader(streamContent.GetStream())) From e7b37b703126777824cb6adc68ed4354c7974761 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 14:26:47 +0800 Subject: [PATCH 06/16] Generate Angular proxies with responseType and Accept from ContentTypes Refs abpframework/abp#23732 --- .../__name@kebab__.service.ts.template | 10 +- .../schematics/src/models/api-definition.ts | 7 +- .../packages/schematics/src/models/method.ts | 2 + .../src/tests/action-to-body-mapper.spec.ts | 543 ++++++++++++++++++ .../proxy-service-template-render.spec.ts | 179 ++++++ .../packages/schematics/src/utils/service.ts | 103 +++- 6 files changed, 838 insertions(+), 6 deletions(-) create mode 100644 npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts create mode 100644 npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts diff --git a/npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template b/npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template index c445286162..22443a5a88 100644 --- a/npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template +++ b/npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template @@ -11,15 +11,17 @@ export class <%= name %>Service { <% const isBlob = body.isBlobMethod() ; const responseType = isBlob ? "Blob":body.responseType; + const httpResponseType = body.httpResponseType; + const acceptHeader = body.acceptHeader; %> <%= camel(signature.name) %> = (<%= serializeParameters(signature.parameters) %>) => this.restService.request<<%= body.requestType %>, <%= responseType %>>({ method: '<%= body.method %>',<% - if (body.responseType === 'string') { %> - responseType: 'text',<% } %><% - if (isBlob) { %> - responseType: 'blob',<% } %> + if (httpResponseType && httpResponseType !== 'json') { %> + responseType: '<%= httpResponseType %>',<% } %><% + if (acceptHeader) { %> + headers: { Accept: '<%= acceptHeader %>' },<% } %> url: <%= body.url %>,<% if (body.dictParamVar && !body.params.length) { %> params: <%= body.dictParamVar %>,<% } %><% diff --git a/npm/ng-packs/packages/schematics/src/models/api-definition.ts b/npm/ng-packs/packages/schematics/src/models/api-definition.ts index 70dca68dd5..3e6458e91d 100644 --- a/npm/ng-packs/packages/schematics/src/models/api-definition.ts +++ b/npm/ng-packs/packages/schematics/src/models/api-definition.ts @@ -68,7 +68,7 @@ export interface Action { supportedVersions: string[]; parametersOnMethod: ParameterInSignature[]; parameters: ParameterInBody[]; - returnValue: TypeDef; + returnValue: ReturnValueDef; } export interface ParameterInSignature { @@ -100,6 +100,11 @@ export interface TypeDef { typeSimple: string; } +export interface ReturnValueDef extends TypeDef { + contentTypes?: string[]; + isRemoteStream?: boolean; +} + export interface TypeWithEnum { isEnum: boolean; type: string; diff --git a/npm/ng-packs/packages/schematics/src/models/method.ts b/npm/ng-packs/packages/schematics/src/models/method.ts index 404fa9d233..920bddba0f 100644 --- a/npm/ng-packs/packages/schematics/src/models/method.ts +++ b/npm/ng-packs/packages/schematics/src/models/method.ts @@ -45,6 +45,8 @@ export class Body { responseTypeWithNamespace: string; requestType = 'any'; responseType: string; + httpResponseType?: 'json' | 'text' | 'blob' | 'arraybuffer'; + acceptHeader?: string; url: string; registerActionParameter = (param: ParameterInBody) => { diff --git a/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts b/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts new file mode 100644 index 0000000000..83984e7a4b --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts @@ -0,0 +1,543 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { describe, expect, test } from 'vitest'; +import { eBindingSourceId } from '../enums'; +import { Action } from '../models'; +import { createActionToBodyMapper } from '../utils/service'; + +const TEMPLATE_PATH = join( + __dirname, + '..', + 'commands', + 'api', + 'files-service', + 'proxy', + '__namespace@dir__', + '__name@kebab__.service.ts.template', +); + +function buildAction(overrides: Partial): Action { + return { + uniqueName: 'GetStatusAsync', + name: 'GetStatus', + httpMethod: 'GET', + url: 'api/app/test-service/status', + supportedVersions: [], + parametersOnMethod: [], + parameters: [], + returnValue: { type: 'System.String', typeSimple: 'string' }, + ...overrides, + } as Action; +} + +describe('createActionToBodyMapper — string return value', () => { + const mapBody = createActionToBodyMapper(); + + test('without contentTypes falls back to text mode (legacy behavior)', () => { + const body = mapBody(buildAction({})); + + expect(body.responseType).toBe('string'); + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('with contentTypes containing application/json picks json + Accept: application/json', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['application/json', 'text/plain'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('with only text/* contentTypes picks text + Accept: text/plain', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['text/plain', 'text/csv'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBe('text/plain'); + }); +}); + +describe('createActionToBodyMapper — IRemoteStreamContent return value', () => { + const mapBody = createActionToBodyMapper(); + + test('always picks blob + Accept: application/octet-stream regardless of contentTypes', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.IRemoteStreamContent', + typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + expect(body.isBlobMethod()).toBe(true); + }); + + test('binary-only contentTypes also picks blob + octet-stream', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.Byte[]', + typeSimple: 'byte[]', + contentTypes: ['application/pdf'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); +}); + +describe('createActionToBodyMapper — other return values', () => { + const mapBody = createActionToBodyMapper(); + + test('object return without contentTypes has no httpResponseType / acceptHeader (defaults to json)', () => { + const body = mapBody( + buildAction({ + returnValue: { type: 'My.Project.UserDto', typeSimple: 'My.Project.UserDto' }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('void return has no httpResponseType / acceptHeader', () => { + const body = mapBody( + buildAction({ + returnValue: { type: 'System.Void', typeSimple: 'void' }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('registers a query parameter via the binding source', () => { + const body = mapBody( + buildAction({ + parameters: [ + { + nameOnMethod: 'id', + name: 'id', + jsonName: null, + type: 'System.Guid', + typeSimple: 'string', + isOptional: false, + defaultValue: null, + constraintTypes: null, + bindingSourceId: eBindingSourceId.Query, + descriptorName: '', + }, + ], + }), + ); + + expect(body.params).toEqual(['id']); + }); +}); + +describe('createActionToBodyMapper — IsRemoteStream backend flag', () => { + const mapBody = createActionToBodyMapper(); + + test('isRemoteStream=true forces blob even if Type is a custom subclass name', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'My.Project.CustomStreamContent', + typeSimple: 'My.Project.CustomStreamContent', + isRemoteStream: true, + contentTypes: ['text/plain', 'application/json'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); + + test('isRemoteStream=false with stream-content type-name still detected by type name (legacy)', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.IRemoteStreamContent', + typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); +}); + +describe('createActionToBodyMapper — +json suffix detection', () => { + const mapBody = createActionToBodyMapper(); + + test('application/problem+json picked as json', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['application/problem+json'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('text/json picked as json (informal but real-world)', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['text/json'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('application/vnd.api+json picked as json', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['application/vnd.api+json'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + }); +}); + +describe('createActionToBodyMapper — expanded binary whitelist', () => { + const mapBody = createActionToBodyMapper(); + + test.each([ + ['application/wasm'], + ['font/woff2'], + ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + ['application/vnd.ms-excel'], + ['application/vnd.oasis.opendocument.spreadsheet'], + ['application/x-msdownload'], + ['application/rtf'], + ['application/x-rar-compressed'], + ['application/x-bzip2'], + ['application/x-iso9660-image'], + ['application/java-archive'], + ['application/epub+zip'], + ['model/gltf-binary'], + ])('"%s" picked as blob', mediaType => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.Byte[]', + typeSimple: 'byte[]', + contentTypes: [mediaType], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + }); +}); + +describe('createActionToBodyMapper — contentTypes precedence and edge cases', () => { + const mapBody = createActionToBodyMapper(); + + test('isBlobMethod() type detection wins over json in contentTypes', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.RemoteStreamContent', + typeSimple: 'Volo.Abp.Content.RemoteStreamContent', + contentTypes: ['application/json', 'text/plain'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); + + test('IRemoteStreamContent[] array also picked as blob', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.IRemoteStreamContent[]', + typeSimple: 'Volo.Abp.Content.IRemoteStreamContent[]', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); + + test('case-insensitive json detection (APPLICATION/JSON)', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['APPLICATION/JSON'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('image/* contentTypes alone picks blob', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.Byte[]', + typeSimple: 'byte[]', + contentTypes: ['image/png', 'image/jpeg'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); + + test('video/* and audio/* picked as blob', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.Byte[]', + typeSimple: 'byte[]', + contentTypes: ['video/mp4', 'audio/mpeg'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + }); + + test('application/pdf contentTypes picked as blob', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.Byte[]', + typeSimple: 'byte[]', + contentTypes: ['application/pdf'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + }); + + test('empty contentTypes falls through to legacy string→text behavior', () => { + const body = mapBody( + buildAction({ + returnValue: { type: 'System.String', typeSimple: 'string', contentTypes: [] }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('mixed text/* and application/json picks json', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['text/json', 'text/plain', 'application/json'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('contentTypes with json-suffix variants still picks json', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['application/json; charset=utf-8'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + }); + + test('non-string non-blob type with contentTypes containing json defaults appropriately', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'My.Project.UserDto', + typeSimple: 'My.Project.UserDto', + contentTypes: ['application/json'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('json contentType with charset parameter is normalized', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['application/json; charset=utf-8'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('text contentType with charset parameter is normalized', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['text/plain ; charset=utf-8 '], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBe('text/plain'); + }); + + test('mixed text/plain (with charset) and application/json picks json after normalize', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['text/plain; charset=utf-8', 'application/json; charset=utf-8'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + }); + + test('text/csv only (custom text format) picks text', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['text/csv'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBe('text/plain'); + }); +}); + +describe('createActionToBodyMapper — backward compatibility', () => { + const mapBody = createActionToBodyMapper(); + + test('legacy api-definition without contentTypes works (string)', () => { + const body = mapBody( + buildAction({ + returnValue: { type: 'System.String', typeSimple: 'string' } as Partial['returnValue'], + } as Partial), + ); + + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('legacy api-definition without contentTypes works (IRemoteStreamContent)', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.IRemoteStreamContent', + typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); + + test('legacy api-definition without contentTypes works (object)', () => { + const body = mapBody( + buildAction({ + returnValue: { type: 'My.Project.UserDto', typeSimple: 'My.Project.UserDto' }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('void / no return preserves legacy behavior', () => { + const body = mapBody( + buildAction({ + returnValue: { type: 'System.Void', typeSimple: 'void' }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + }); +}); + +describe('proxy service template emission', () => { + const template = readFileSync(TEMPLATE_PATH, 'utf8'); + + test('reads body.httpResponseType and body.acceptHeader from body', () => { + expect(template).toContain('body.httpResponseType'); + expect(template).toContain('body.acceptHeader'); + }); + + test('emits Accept header conditional', () => { + expect(template).toMatch(/headers:\s*\{\s*Accept:/); + }); + + test('emits responseType only for non-json httpResponseType', () => { + expect(template).toMatch(/httpResponseType\s*&&\s*httpResponseType\s*!==\s*'json'/); + }); +}); diff --git a/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts b/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts new file mode 100644 index 0000000000..fb611c0088 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts @@ -0,0 +1,179 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { template as lodashTemplate } from 'lodash'; +import { describe, expect, test } from 'vitest'; + +/** + * Smoke test that actually renders the proxy `.service.ts.template` against + * representative body configurations and asserts the emitted code matches + * what the runtime contract requires. + * + * This catches template-syntax / control-flow regressions that a string + * `toContain` check on the template source would silently let through. + */ + +const TEMPLATE_PATH = join( + __dirname, + '..', + 'commands', + 'api', + 'files-service', + 'proxy', + '__namespace@dir__', + '__name@kebab__.service.ts.template', +); + +const TEMPLATE_SRC = readFileSync(TEMPLATE_PATH, 'utf8'); + +function render(context: Record): string { + const compiled = lodashTemplate(TEMPLATE_SRC, { + imports: { + camel: (s: string) => s.charAt(0).toLowerCase() + s.slice(1), + serializeParameters: (params: Array<{ name: string; type: string; default?: string }>) => + params.map(p => `${p.name}: ${p.type}`).join(', '), + }, + }); + return compiled(context); +} + +function buildContext(body: Partial) { + return { + apiName: 'Default', + name: 'Sample', + namespace: 'app', + imports: [ + { keyword: 'import', specifiers: ['RestService', 'Rest'], path: '@abp/ng.core' }, + { keyword: 'import', specifiers: ['Injectable', 'inject'], path: '@angular/core' }, + ], + methods: [ + { + body: makeBody(body), + signature: { + name: 'GetSampleAsync', + parameters: [], + }, + }, + ], + }; +} + +interface MockBody { + method: string; + url: string; + responseType: string; + responseTypeWithNamespace: string; + httpResponseType?: string; + acceptHeader?: string; + body?: string; + params: string[]; + dictParamVar?: string; + requestType: string; + isBlobMethod(): boolean; +} + +function makeBody(overrides: Partial): MockBody { + return { + method: 'GET', + url: "'/api/sample'", + responseType: 'any', + responseTypeWithNamespace: 'any', + httpResponseType: undefined, + acceptHeader: undefined, + body: undefined, + params: [], + dictParamVar: undefined, + requestType: 'any', + isBlobMethod: () => false, + ...overrides, + }; +} + +describe('proxy service template — rendered output', () => { + test('default JSON body emits no responseType and no Accept header', () => { + const output = render(buildContext({ + responseType: 'MyDto', + responseTypeWithNamespace: 'My.Project.MyDto', + })); + + expect(output).toContain("method: 'GET'"); + expect(output).not.toContain('responseType:'); + expect(output).not.toContain('headers:'); + }); + + test('json httpResponseType emits Accept but no responseType (default is json)', () => { + const output = render(buildContext({ + responseType: 'string', + responseTypeWithNamespace: 'string', + httpResponseType: 'json', + acceptHeader: 'application/json', + })); + + expect(output).toContain("headers: { Accept: 'application/json' }"); + expect(output).not.toContain('responseType:'); + }); + + test('text httpResponseType emits both responseType and Accept', () => { + const output = render(buildContext({ + responseType: 'string', + responseTypeWithNamespace: 'string', + httpResponseType: 'text', + acceptHeader: 'text/plain', + })); + + expect(output).toContain("responseType: 'text'"); + expect(output).toContain("headers: { Accept: 'text/plain' }"); + }); + + test('blob (IRemoteStreamContent) emits Blob return type + responseType + Accept', () => { + const output = render(buildContext({ + responseType: 'Volo.Abp.Content.IRemoteStreamContent', + responseTypeWithNamespace: 'Volo.Abp.Content.IRemoteStreamContent', + isBlobMethod: () => true, + httpResponseType: 'blob', + acceptHeader: 'application/octet-stream', + })); + + expect(output).toContain("responseType: 'blob'"); + expect(output).toContain('Blob>'); + expect(output).toContain("headers: { Accept: 'application/octet-stream' }"); + }); + + test('arraybuffer httpResponseType emits responseType', () => { + const output = render(buildContext({ + responseType: 'ArrayBuffer', + responseTypeWithNamespace: 'ArrayBuffer', + httpResponseType: 'arraybuffer', + acceptHeader: 'application/octet-stream', + })); + + expect(output).toContain("responseType: 'arraybuffer'"); + expect(output).toContain("headers: { Accept: 'application/octet-stream' }"); + }); + + test('no acceptHeader → no headers line', () => { + const output = render(buildContext({ + responseType: 'string', + responseTypeWithNamespace: 'string', + httpResponseType: 'text', + acceptHeader: undefined, + })); + + expect(output).toContain("responseType: 'text'"); + expect(output).not.toContain('headers:'); + }); + + test('rendered service code is valid TypeScript-shaped (closing braces / semicolons)', () => { + const output = render(buildContext({ + responseType: 'string', + responseTypeWithNamespace: 'string', + httpResponseType: 'json', + acceptHeader: 'application/json', + })); + + expect(output).toContain('@Injectable({'); + expect(output).toContain('providedIn: \'root\''); + expect(output).toContain('export class SampleService'); + expect(output).toContain('this.restService.request 0) { + const normalized = contentTypes.map(normalizeMediaType); + + if (normalized.some(isJsonMediaType)) { + return { httpResponseType: 'json', acceptHeader: 'application/json' }; + } + + if (normalized.every(ct => ct.startsWith('text/'))) { + return { httpResponseType: 'text', acceptHeader: 'text/plain' }; + } + + if (normalized.every(isBinaryMediaType)) { + return { httpResponseType: 'blob', acceptHeader: 'application/octet-stream' }; + } + } + + if (responseType === 'string') { + return { httpResponseType: 'text' }; + } + + return {}; +} + export function createActionToSignatureMapper() { const adaptType = createTypeAdapter(); From 02c6da18cc106ccc836c0544d39b65fc505f1c74 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 14:27:06 +0800 Subject: [PATCH 07/16] Auto-set Accept header and unwrap ABP JSON error envelopes in RestService Refs abpframework/abp#23732 --- .../core/src/lib/services/rest.service.ts | 65 +++- .../core/src/lib/tests/rest.service.spec.ts | 291 ++++++++++++++++++ 2 files changed, 354 insertions(+), 2 deletions(-) diff --git a/npm/ng-packs/packages/core/src/lib/services/rest.service.ts b/npm/ng-packs/packages/core/src/lib/services/rest.service.ts index a2d45e2f12..65a98d15f1 100644 --- a/npm/ng-packs/packages/core/src/lib/services/rest.service.ts +++ b/npm/ng-packs/packages/core/src/lib/services/rest.service.ts @@ -1,4 +1,4 @@ -import { HttpClient, HttpParameterCodec, HttpParams, HttpRequest } from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpParameterCodec, HttpParams, HttpRequest } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { Observable, throwError } from 'rxjs'; import { catchError } from 'rxjs/operators'; @@ -39,7 +39,10 @@ export class RestService { api = api || this.getApiFromStore(config.apiName); const { method, params, ...options } = request; const { observe = Rest.Observe.Body, skipHandleError, responseType = Rest.ResponseType.JSON } = config; + const effectiveResponseType = + ((request as Rest.Request).responseType as Rest.ResponseType | undefined) ?? responseType; const url = this.removeDuplicateSlashes(api + request.url); + const headers = this.ensureAcceptHeader(options.headers, effectiveResponseType); const httpClient: HttpClient = this.getHttpClient(config.skipAddingHeader); return httpClient @@ -50,13 +53,71 @@ export class RestService { params: this.getParams(params, config.httpParamEncoder), }), ...options, + ...(headers && { headers }), } as any) - .pipe(catchError(err => (skipHandleError ? throwError(() => err) : this.handleError(err)))); + .pipe( + catchError(err => { + if (!skipHandleError) { + this.tryUnwrapJsonErrorBody(err, effectiveResponseType); + } + return skipHandleError ? throwError(() => err) : this.handleError(err); + }), + ); } private getHttpClient(isExternal: boolean) { return isExternal ? this.externalHttp : this.http; } + private ensureAcceptHeader( + headers: Rest.Request['headers'], + responseType: Rest.ResponseType, + ): Rest.Request['headers'] | undefined { + const accept = this.getAcceptForResponseType(responseType); + if (!accept) return headers; + if (this.hasAcceptHeader(headers)) return headers; + if (headers instanceof HttpHeaders) { + return headers.set('Accept', accept); + } + return { ...(headers || {}), Accept: accept }; + } + + private hasAcceptHeader(headers: Rest.Request['headers']): boolean { + if (!headers) return false; + if (headers instanceof HttpHeaders) return headers.has('Accept'); + return Object.keys(headers).some(key => key.toLowerCase() === 'accept'); + } + + private getAcceptForResponseType(responseType: Rest.ResponseType): string | null { + switch (responseType) { + case Rest.ResponseType.Blob: + case Rest.ResponseType.ArrayBuffer: + return 'application/octet-stream'; + default: + return null; + } + } + + private tryUnwrapJsonErrorBody(err: any, responseType: Rest.ResponseType): void { + if (responseType === Rest.ResponseType.JSON || !err) return; + if (typeof err.error !== 'string' || err.error.length === 0) return; + let parsed: any; + try { + parsed = JSON.parse(err.error); + } catch { + return; + } + if (this.looksLikeAbpErrorEnvelope(parsed)) { + err.error = parsed; + } + } + + private looksLikeAbpErrorEnvelope(parsed: any): boolean { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false; + const inner = parsed.error; + if (!inner || typeof inner !== 'object') return false; + return typeof inner.code === 'string' || typeof inner.message === 'string'; + } + private getParams(params: Rest.Params, encoder?: HttpParameterCodec): HttpParams { const filteredParams = Object.entries(params).reduce((acc, [key, value]) => { if (isUndefinedOrEmptyString(value)) return acc; diff --git a/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts index 9ac795c24f..cf967a1d47 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts @@ -1,3 +1,4 @@ +import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { createHttpFactory, HttpMethod, SpectatorHttp, SpyObject } from '@ngneat/spectator/vitest'; import { OAuthService } from 'angular-oauth2-oidc'; import { of, throwError } from 'rxjs'; @@ -130,6 +131,296 @@ describe('HttpClient testing', () => { spectator.flushAll([req], [throwError('Testing error')]); }); + test('should set Accept: application/octet-stream when config.responseType is blob', () => { + spectator.service + .request({ method: HttpMethod.GET, url: '/file' }, { responseType: Rest.ResponseType.Blob }) + .subscribe(); + const req = spectator.expectOne(api + '/file', HttpMethod.GET); + expect(req.request.headers.get('Accept')).toEqual('application/octet-stream'); + expect(req.request.responseType).toEqual('blob'); + }); + + test('should set Accept based on request-level responseType (generator path)', () => { + spectator.service + .request({ method: HttpMethod.GET, url: '/file', responseType: 'blob' }) + .subscribe(); + const req = spectator.expectOne(api + '/file', HttpMethod.GET); + expect(req.request.headers.get('Accept')).toEqual('application/octet-stream'); + expect(req.request.responseType).toEqual('blob'); + }); + + test('should set Accept: application/octet-stream when responseType is arraybuffer', () => { + spectator.service + .request( + { method: HttpMethod.GET, url: '/binary' }, + { responseType: Rest.ResponseType.ArrayBuffer }, + ) + .subscribe(); + const req = spectator.expectOne(api + '/binary', HttpMethod.GET); + expect(req.request.headers.get('Accept')).toEqual('application/octet-stream'); + }); + + test('should NOT add Accept for text responseType (left to schematic / caller)', () => { + spectator.service + .request({ method: HttpMethod.GET, url: '/text' }, { responseType: Rest.ResponseType.Text }) + .subscribe(); + const req = spectator.expectOne(api + '/text', HttpMethod.GET); + expect(req.request.headers.has('Accept')).toBe(false); + }); + + test('should not override caller-supplied Accept header (plain object)', () => { + spectator.service + .request( + { method: HttpMethod.GET, url: '/file', headers: { Accept: 'image/png' } }, + { responseType: Rest.ResponseType.Blob }, + ) + .subscribe(); + const req = spectator.expectOne(api + '/file', HttpMethod.GET); + expect(req.request.headers.get('Accept')).toEqual('image/png'); + }); + + test('should not override caller-supplied Accept header (HttpHeaders)', () => { + spectator.service + .request( + { + method: HttpMethod.GET, + url: '/file', + headers: new HttpHeaders({ Accept: 'image/jpeg' }), + }, + { responseType: Rest.ResponseType.Blob }, + ) + .subscribe(); + const req = spectator.expectOne(api + '/file', HttpMethod.GET); + expect(req.request.headers.get('Accept')).toEqual('image/jpeg'); + }); + + test('should preserve caller-supplied non-Accept headers and add Accept', () => { + spectator.service + .request( + { method: HttpMethod.GET, url: '/file', headers: { 'X-Custom': '1' } }, + { responseType: Rest.ResponseType.Blob }, + ) + .subscribe(); + const req = spectator.expectOne(api + '/file', HttpMethod.GET); + expect(req.request.headers.get('Accept')).toEqual('application/octet-stream'); + expect(req.request.headers.get('X-Custom')).toEqual('1'); + }); + + test('should not add Accept header for default JSON responseType', () => { + spectator.service.request({ method: HttpMethod.GET, url: '/json' }).subscribe(); + const req = spectator.expectOne(api + '/json', HttpMethod.GET); + expect(req.request.headers.has('Accept')).toBe(false); + }); + + test('should NOT unwrap error body when skipHandleError is true', async () => { + const spy = vi.spyOn(httpErrorReporter, 'reportError'); + + const completion = new Promise((resolve, reject) => { + spectator.service + .request( + { method: HttpMethod.GET, url: '/text' }, + { responseType: Rest.ResponseType.Text, skipHandleError: true }, + ) + .pipe( + catchError(err => { + try { + expect(spy).toHaveBeenCalledTimes(0); + expect(typeof err.error).toBe('string'); + expect(err.error).toBe('{"error":{"code":"X","message":"y"}}'); + resolve(); + } catch (e) { + reject(e); + } + return of(null); + }), + ) + .subscribe(); + }); + + const req = spectator.expectOne(api + '/text', HttpMethod.GET); + req.flush('{"error":{"code":"X","message":"y"}}', { + status: 500, + statusText: 'Internal Server Error', + }); + + await completion; + }); + + test('should unwrap ABP validationErrors envelope in text mode', async () => { + const spy = vi.spyOn(httpErrorReporter, 'reportError'); + + const completion = new Promise((resolve, reject) => { + spectator.service + .request({ method: HttpMethod.GET, url: '/text' }, { responseType: Rest.ResponseType.Text }) + .pipe( + catchError(() => { + try { + const errArg: any = spy.mock.calls[0][0]; + expect(typeof errArg.error).toBe('object'); + expect(errArg.error.error.message).toBe('Validation failed'); + expect(errArg.error.error.validationErrors).toHaveLength(1); + resolve(); + } catch (e) { + reject(e); + } + return of(null); + }), + ) + .subscribe(); + }); + + const req = spectator.expectOne(api + '/text', HttpMethod.GET); + req.flush( + '{"error":{"message":"Validation failed","validationErrors":[{"message":"Required","members":["Name"]}]}}', + { status: 400, statusText: 'Bad Request' }, + ); + + await completion; + }); + + test('should leave non-ABP-envelope JSON body alone in text mode', async () => { + const completion = new Promise((resolve, reject) => { + spectator.service + .request({ method: HttpMethod.GET, url: '/text' }, { responseType: Rest.ResponseType.Text }) + .pipe( + catchError(err => { + try { + // JSON parses fine but no error.code/error.message → keep raw string + expect(typeof err.error).toBe('string'); + expect(err.error).toBe('{"foo":"bar"}'); + resolve(); + } catch (e) { + reject(e); + } + return of(null); + }), + ) + .subscribe(); + }); + + const req = spectator.expectOne(api + '/text', HttpMethod.GET); + req.flush('{"foo":"bar"}', { status: 500, statusText: 'err' }); + + await completion; + }); + + test('should unwrap JSON-encoded error body in text mode for HttpErrorReporter', async () => { + const spy = vi.spyOn(httpErrorReporter, 'reportError'); + + const completion = new Promise((resolve, reject) => { + spectator.service + .request( + { method: HttpMethod.GET, url: '/text' }, + { responseType: Rest.ResponseType.Text }, + ) + .pipe( + catchError(() => { + try { + expect(spy).toHaveBeenCalledTimes(1); + const errArg: any = spy.mock.calls[0][0]; + expect(errArg.error).toEqual({ + error: { code: 'AbpAuthorization.001', message: 'forbidden' }, + }); + resolve(); + } catch (e) { + reject(e); + } + return of(null); + }), + ) + .subscribe(); + }); + + const req = spectator.expectOne(api + '/text', HttpMethod.GET); + req.flush('{"error":{"code":"AbpAuthorization.001","message":"forbidden"}}', { + status: 403, + statusText: 'Forbidden', + }); + + await completion; + }); + + test('should leave non-JSON error body alone in text mode', async () => { + const spy = vi.spyOn(httpErrorReporter, 'reportError'); + + const completion = new Promise((resolve, reject) => { + spectator.service + .request( + { method: HttpMethod.GET, url: '/text' }, + { responseType: Rest.ResponseType.Text }, + ) + .pipe( + catchError(() => { + try { + const errArg: any = spy.mock.calls[0][0]; + expect(errArg.error).toBe('plain error text'); + resolve(); + } catch (e) { + reject(e); + } + return of(null); + }), + ) + .subscribe(); + }); + + const req = spectator.expectOne(api + '/text', HttpMethod.GET); + req.flush('plain error text', { status: 500, statusText: 'Internal Server Error' }); + + await completion; + }); + + test('should send Accept header emitted by schematic verbatim (json scenario)', () => { + spectator.service + .request({ + method: HttpMethod.GET, + url: '/status', + headers: { Accept: 'application/json' }, + }) + .subscribe(); + const req = spectator.expectOne(api + '/status', HttpMethod.GET); + expect(req.request.headers.get('Accept')).toEqual('application/json'); + }); + + test('should send Accept header emitted by schematic verbatim (text scenario)', () => { + spectator.service + .request({ method: HttpMethod.GET, url: '/csv', headers: { Accept: 'text/plain' } }) + .subscribe(); + const req = spectator.expectOne(api + '/csv', HttpMethod.GET); + expect(req.request.headers.get('Accept')).toEqual('text/plain'); + }); + + test('should leave JSON error body alone in json mode (no double-parse)', async () => { + const spy = vi.spyOn(httpErrorReporter, 'reportError'); + + const completion = new Promise((resolve, reject) => { + spectator.service + .request({ method: HttpMethod.GET, url: '/json' }) + .pipe( + catchError(() => { + try { + const errArg: any = spy.mock.calls[0][0]; + // Angular HttpClient already parsed JSON in json mode → err.error is object + expect(errArg.error).toEqual({ error: { code: 'X', message: 'y' } }); + resolve(); + } catch (e) { + reject(e); + } + return of(null); + }), + ) + .subscribe(); + }); + + const req = spectator.expectOne(api + '/json', HttpMethod.GET); + req.flush( + { error: { code: 'X', message: 'y' } }, + { status: 403, statusText: 'Forbidden' }, + ); + + await completion; + }); + test('should remove the duplicate slashes', () => { spectator.service .request({ method: HttpMethod.GET, url: '//test', params: { id: 1 } }) From fba75de4153d283709a32d9cc450633995e977ef Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 15:18:33 +0800 Subject: [PATCH 08/16] Skip dataType override for IRemoteStreamContent in jQuery proxy generator --- .../JQuery/JQueryProxyScriptGenerator.cs | 9 ++++++++ ...ProxyScriptGenerator_ContentTypes_Tests.cs | 23 ++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs index ad44a98316..4502598c75 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs @@ -146,6 +146,15 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen private static string GetJQueryDataTypeAndAcceptOverride(ActionApiDescriptionModel action) { + // jQuery doesn't natively support binary downloads, so don't override + // dataType/Accept for remote stream returns — letting the server pick the + // formatter naturally avoids forcing JSON metadata in place of the binary + // payload (which would re-introduce the original IRemoteStreamContent bug). + if (action.ReturnValue.IsRemoteStream) + { + return string.Empty; + } + var contentTypes = action.ReturnValue.ContentTypes; var isStringReturn = action.ReturnValue.Type == ReturnValueApiDescriptionModel.Create(typeof(string)).Type; diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs index ee0a20114c..0eacd51628 100644 --- a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs @@ -93,7 +93,27 @@ public class JQueryProxyScriptGenerator_ContentTypes_Tests script.ShouldContain("dataType: 'json'"); } - private static ApplicationApiDescriptionModel BuildAppModel(string returnType, IList? contentTypes) + [Fact] + public void IsRemoteStream_Should_Skip_DataType_Override_To_Avoid_JSON_Metadata_Regression() + { + // For IRemoteStreamContent returns the API definition still advertises + // application/json (server-side default formatter list). The generator + // MUST NOT force dataType:'json' + Accept:'application/json' for these — + // doing so makes the server JSON-serialise the IRemoteStreamContent object + // and re-introduces the original IRemoteStreamContent bug. jQuery doesn't + // natively support binary downloads, so we let the legacy behavior stand. + var model = BuildAppModel( + returnType: "Volo.Abp.Content.IRemoteStreamContent", + contentTypes: new[] { "text/plain", "application/json", "text/json" }, + isRemoteStream: true); + + var script = _generator.CreateScript(model); + + script.ShouldNotContain("dataType: 'json'"); + script.ShouldNotContain("Accept: 'application/json'"); + } + + private static ApplicationApiDescriptionModel BuildAppModel(string returnType, IList? contentTypes, bool isRemoteStream = false) { var model = ApplicationApiDescriptionModel.Create(); var module = model.GetOrAddModule("app", "Default"); @@ -119,6 +139,7 @@ public class JQueryProxyScriptGenerator_ContentTypes_Tests Type = returnType, TypeSimple = returnType, ContentTypes = contentTypes, + IsRemoteStream = isRemoteStream, }, AuthorizeDatas = new List(), }; From cb7e8aff68994e97a580a9963e0878ee4baeb01a Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 18:24:12 +0800 Subject: [PATCH 09/16] Refine ClientProxyBase Accept handling and narrow remote stream cast --- .../Client/ClientProxying/ClientProxyBase.cs | 60 +++++----- .../ReturnValueApiDescriptionModel.cs | 23 +--- .../ClientProxyBase_ContentTypes_Tests.cs | 103 ++++++++++++++++-- .../DynamicProxying/IRegularTestController.cs | 2 + .../DynamicProxying/RegularTestController.cs | 7 ++ ...lerClientProxy_ReturnContentTypes_Tests.cs | 15 +-- .../ReturnValueApiDescriptionModel_Tests.cs | 34 +++--- 7 files changed, 153 insertions(+), 91 deletions(-) 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 e3d4a06dfb..0b7a21c71e 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,7 +108,7 @@ public class ClientProxyBase : ITransientDependency { var responseContent = await RequestAsync(requestContext); - if (typeof(T).IsAssignableFrom(typeof(RemoteStreamContent))) + if (typeof(T) == typeof(IRemoteStreamContent) || typeof(T) == typeof(RemoteStreamContent)) { /* returning a class that holds a reference to response * content just to be sure that GC does not dispose of @@ -153,7 +153,6 @@ public class ClientProxyBase : ITransientDependency 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; } @@ -170,7 +169,7 @@ public class ClientProxyBase : ITransientDependency return string.Empty; } var semi = mediaType.IndexOf(';'); - return (semi < 0 ? mediaType : mediaType.Substring(0, semi)).Trim(); + return (semi < 0 ? mediaType : mediaType.Substring(0, semi)).Trim().ToLowerInvariant(); } protected virtual async Task RequestAsync(ClientProxyRequestContext requestContext) @@ -360,24 +359,7 @@ public class ClientProxyBase : ITransientDependency HttpRequestMessage requestMessage, ApiVersionInfo apiVersion) { - //API Version - if (!apiVersion.Version.IsNullOrEmpty()) - { - //TODO: What about other media types? - requestMessage.Headers.Add("accept", $"{MimeTypes.Text.Plain}; v={apiVersion.Version}"); - requestMessage.Headers.Add("accept", $"{MimeTypes.Application.Json}; v={apiVersion.Version}"); - 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); - } - } + AddAcceptHeaders(action, requestMessage, apiVersion); //Header parameters var headers = action.Parameters.Where(p => p.BindingSourceId == ParameterBindingSources.Header).ToArray(); @@ -422,6 +404,30 @@ public class ClientProxyBase : ITransientDependency } } + protected virtual void AddAcceptHeaders( + ActionApiDescriptionModel action, + HttpRequestMessage requestMessage, + ApiVersionInfo apiVersion) + { + var acceptForReturn = GetAcceptForActionReturn(action); + var versionSuffix = apiVersion.Version.IsNullOrEmpty() ? string.Empty : $"; v={apiVersion.Version}"; + + if (!acceptForReturn.IsNullOrEmpty()) + { + requestMessage.Headers.Add("accept", acceptForReturn + versionSuffix); + } + else + { + requestMessage.Headers.Add("accept", MimeTypes.Text.Plain + versionSuffix); + requestMessage.Headers.Add("accept", MimeTypes.Application.Json + versionSuffix); + } + + if (!apiVersion.Version.IsNullOrEmpty()) + { + requestMessage.Headers.Add("api-version", apiVersion.Version); + } + } + protected virtual string? GetAcceptForActionReturn(ActionApiDescriptionModel action) { if (action.ReturnValue.IsRemoteStream || @@ -439,17 +445,7 @@ public class ClientProxyBase : ITransientDependency 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; + return normalized.FirstOrDefault(IsJsonMediaType) ?? normalized[0]; } private static bool IsJsonMediaType(string normalizedMediaType) 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 dcd1b16e6b..25b2fb149a 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,5 +1,4 @@ using System; -using System.Collections; using System.Collections.Generic; using Volo.Abp.Content; using Volo.Abp.Reflection; @@ -40,26 +39,6 @@ public class ReturnValueApiDescriptionModel 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; + return type == typeof(IRemoteStreamContent) || type == typeof(RemoteStreamContent); } } 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 index b2acdfedbb..c30fbb0d43 100644 --- 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 @@ -1,5 +1,7 @@ #nullable enable using System.Collections.Generic; +using System.Linq; +using System.Net.Http; using Shouldly; using Volo.Abp.Content; using Volo.Abp.Http.Modeling; @@ -57,13 +59,13 @@ public class ClientProxyBase_GetAcceptForActionReturn_Tests } [Fact] - public void Mixed_Text_And_Octet_Stream_Should_Not_Pick_TextPlain() + public void Mixed_Text_And_Octet_Stream_Should_Echo_First_Content_Type() { var action = BuildAction( returnType: "System.String", contentTypes: new[] { "text/plain", "application/octet-stream" }); - InvokeGetAcceptForActionReturn(action).ShouldBeNull(); + InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain"); } [Fact] @@ -77,23 +79,33 @@ public class ClientProxyBase_GetAcceptForActionReturn_Tests } [Fact] - public void Single_TextHtml_Should_Pick_TextPlain() + public void Single_TextHtml_Should_Echo_Back_TextHtml() { var action = BuildAction( returnType: "System.String", contentTypes: new[] { "text/html" }); - InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain"); + InvokeGetAcceptForActionReturn(action).ShouldBe("text/html"); } [Fact] - public void OctetStream_Only_With_ObjectReturn_Should_Return_Null() + public void OctetStream_Only_With_ObjectReturn_Should_Echo_OctetStream() { var action = BuildAction( returnType: "My.Project.UserDto", contentTypes: new[] { "application/octet-stream" }); - InvokeGetAcceptForActionReturn(action).ShouldBeNull(); + InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream"); + } + + [Fact] + public void ApplicationXml_Only_Should_Echo_Back_Xml_Instead_Of_Legacy_Pair() + { + var action = BuildAction( + returnType: "My.Project.SoapEnvelope", + contentTypes: new[] { "application/xml" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/xml"); } [Fact] @@ -127,23 +139,33 @@ public class ClientProxyBase_GetAcceptForActionReturn_Tests } [Fact] - public void Text_Json_Should_Be_Treated_As_Json() + public void Text_Json_Should_Echo_Back_Text_Json() { var action = BuildAction( returnType: "System.String", contentTypes: new[] { "text/json" }); - InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); + InvokeGetAcceptForActionReturn(action).ShouldBe("text/json"); } [Fact] - public void Application_Problem_Json_Should_Be_Treated_As_Json() + public void Application_Problem_Json_Should_Echo_Back_The_Plus_Json_Variant() { var action = BuildAction( returnType: "System.String", contentTypes: new[] { "application/problem+json" }); - InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); + InvokeGetAcceptForActionReturn(action).ShouldBe("application/problem+json"); + } + + [Fact] + public void Vendor_Plus_Json_Should_Echo_Back_The_Plus_Json_Variant() + { + var action = BuildAction( + returnType: "System.String", + contentTypes: new[] { "application/vnd.api+json" }); + + InvokeGetAcceptForActionReturn(action).ShouldBe("application/vnd.api+json"); } [Fact] @@ -184,9 +206,70 @@ public class ClientProxyBase_GetAcceptForActionReturn_Tests }; } + [Fact] + public void AddHeaders_With_ApiVersion_Should_Combine_OctetStream_Accept_With_Version_Suffix() + { + var action = BuildAction( + returnType: typeof(IRemoteStreamContent).FullName!, + contentTypes: new[] { "application/json", "text/plain" }); + + var headers = InvokeAddHeadersAndCollectAccept(action, version: "2.0"); + + headers.ShouldContain("application/octet-stream; v=2.0"); + headers.ShouldNotContain(h => h == "text/plain; v=2.0"); + headers.ShouldNotContain(h => h == "application/json; v=2.0"); + } + + [Fact] + public void AddHeaders_Without_ApiVersion_Should_Emit_OctetStream_For_Stream_Returns() + { + var action = BuildAction( + returnType: typeof(IRemoteStreamContent).FullName!, + contentTypes: new[] { "application/json" }); + + var headers = InvokeAddHeadersAndCollectAccept(action, version: null); + + headers.ShouldContain("application/octet-stream"); + } + + [Fact] + public void AddHeaders_Without_ContentType_Metadata_Should_Fall_Back_To_Text_And_Json_Pair() + { + var action = BuildAction(returnType: "System.Int32", contentTypes: null); + + var headers = InvokeAddHeadersAndCollectAccept(action, version: "1.0"); + + headers.ShouldContain("text/plain; v=1.0"); + headers.ShouldContain("application/json; v=1.0"); + } + + [Fact] + public void AddHeaders_Without_ApiVersion_And_Without_ContentType_Metadata_Should_Emit_Unversioned_Text_Json_Pair() + { + var action = BuildAction(returnType: "System.Int32", contentTypes: null); + + var headers = InvokeAddHeadersAndCollectAccept(action, version: null); + + headers.ShouldContain("text/plain"); + headers.ShouldContain("application/json"); + headers.ShouldNotContain(h => h.Contains("; v=")); + } + + private static IList InvokeAddHeadersAndCollectAccept(ActionApiDescriptionModel action, string? version) + { + var proxy = new TestableClientProxy(); + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/x"); + var apiVersion = new ApiVersionInfo("HeaderModelBinding", version ?? string.Empty); + proxy.PublicAddAcceptHeaders(action, message, apiVersion); + return message.Headers.Accept.Select(a => a.ToString()).ToList(); + } + private sealed class TestableClientProxy : ClientProxyBase { public string? PublicGetAcceptForActionReturn(ActionApiDescriptionModel action) => GetAcceptForActionReturn(action); + + public void PublicAddAcceptHeaders(ActionApiDescriptionModel action, HttpRequestMessage requestMessage, ApiVersionInfo apiVersion) + => AddAcceptHeaders(action, requestMessage, apiVersion); } } diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs index 5be89375a0..fdf279e1ce 100644 --- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs @@ -25,6 +25,8 @@ public interface IRegularTestController Task DownloadIconAsync(); + Task GetReferenceTypeObjectAsync(); + Task GetByteArrayAsync(); Task GetException1Async(); diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs index 437d047b71..1d0864991b 100644 --- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs @@ -85,6 +85,13 @@ public class RegularTestController : AbpController, IRegularTestController new RemoteStreamContent(new MemoryStream(bytes), "icon.bin", "application/octet-stream")); } + [HttpGet] + [Route("reference-type-object")] + public Task GetReferenceTypeObjectAsync() + { + return Task.FromResult(new Car { Year = 1999, Model = "BMW" }); + } + [HttpGet] [Route("byte-array")] public Task GetByteArrayAsync() diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_ReturnContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_ReturnContentTypes_Tests.cs index a6725d6e6c..92c8b5b23c 100644 --- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_ReturnContentTypes_Tests.cs +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_ReturnContentTypes_Tests.cs @@ -57,8 +57,6 @@ public class RegularTestControllerClientProxy_ReturnContentTypes_Tests : AbpHttp [Fact] public async Task GetProducesJsonNullStringAsync_Should_Not_Return_Literal_Null() { - // Server returns JSON `null` body (4 chars). The unwrap MUST NOT pass through "null" literal — - // it should produce empty/null on the client side instead. var result = await _controller.GetProducesJsonNullStringAsync(); result.ShouldNotBe("null"); (result == null || result == string.Empty).ShouldBeTrue(); @@ -67,8 +65,6 @@ public class RegularTestControllerClientProxy_ReturnContentTypes_Tests : AbpHttp [Fact] public async Task GetEscapedStringAsync_Should_Decode_Escaped_Characters() { - // Server JSON-encodes the string with escapes: "a\"b\\c\nd" - // Without unwrap fix client would receive the raw JSON string including escapes. var result = await _controller.GetEscapedStringAsync(); result.ShouldBe("a\"b\\c\nd"); } @@ -83,12 +79,17 @@ public class RegularTestControllerClientProxy_ReturnContentTypes_Tests : AbpHttp content.FileName.ShouldBe("icon.bin"); } + [Fact] + public async Task GetReferenceTypeObjectAsync_Should_Not_Be_Wrapped_As_RemoteStreamContent() + { + var result = await _controller.GetReferenceTypeObjectAsync(); + result.ShouldNotBeNull(); + result.ShouldNotBeAssignableTo(); + } + [Fact] public async Task GetByteArrayAsync_Should_Round_Trip_Bytes() { - // byte[] is not IRemoteStreamContent; goes through default JSON path - // (server JSON-encodes as base64). Ensures our Accept logic didn't break - // the existing non-stream binary case. var bytes = await _controller.GetByteArrayAsync(); bytes.ShouldBe(new byte[] { 1, 2, 3, 4 }); } 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 index c95f5d9be3..d3c3197937 100644 --- 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 @@ -49,10 +49,10 @@ public class ReturnValueApiDescriptionModel_IsRemoteStream_Tests } [Fact] - public void Custom_Subclass_Of_IRemoteStreamContent_Should_Be_True() + public void Custom_Subclass_Of_IRemoteStreamContent_Should_Be_False() { var model = ReturnValueApiDescriptionModel.Create(typeof(MyCustomStreamContent)); - model.IsRemoteStream.ShouldBeTrue(); + model.IsRemoteStream.ShouldBeFalse(); } [Fact] @@ -63,45 +63,45 @@ public class ReturnValueApiDescriptionModel_IsRemoteStream_Tests } [Fact] - public void Task_Of_Custom_Stream_Subclass_Should_Be_True_After_UnwrapTask() + public void Task_Of_Custom_Stream_Subclass_Should_Be_False_After_UnwrapTask() { var model = ReturnValueApiDescriptionModel.Create(typeof(Task)); - model.IsRemoteStream.ShouldBeTrue(); + model.IsRemoteStream.ShouldBeFalse(); } [Fact] - public void IRemoteStreamContent_Array_Should_Be_True() + public void IRemoteStreamContent_Array_Should_Be_False() { var model = ReturnValueApiDescriptionModel.Create(typeof(IRemoteStreamContent[])); - model.IsRemoteStream.ShouldBeTrue(); + model.IsRemoteStream.ShouldBeFalse(); } [Fact] - public void Concrete_RemoteStreamContent_Array_Should_Be_True() + public void Concrete_RemoteStreamContent_Array_Should_Be_False() { var model = ReturnValueApiDescriptionModel.Create(typeof(RemoteStreamContent[])); - model.IsRemoteStream.ShouldBeTrue(); + model.IsRemoteStream.ShouldBeFalse(); } [Fact] - public void List_Of_IRemoteStreamContent_Should_Be_True() + public void List_Of_IRemoteStreamContent_Should_Be_False() { var model = ReturnValueApiDescriptionModel.Create(typeof(List)); - model.IsRemoteStream.ShouldBeTrue(); + model.IsRemoteStream.ShouldBeFalse(); } [Fact] - public void IEnumerable_Of_IRemoteStreamContent_Should_Be_True() + public void IEnumerable_Of_IRemoteStreamContent_Should_Be_False() { var model = ReturnValueApiDescriptionModel.Create(typeof(IEnumerable)); - model.IsRemoteStream.ShouldBeTrue(); + model.IsRemoteStream.ShouldBeFalse(); } [Fact] - public void IReadOnlyCollection_Of_IRemoteStreamContent_Should_Be_True() + public void IReadOnlyCollection_Of_IRemoteStreamContent_Should_Be_False() { var model = ReturnValueApiDescriptionModel.Create(typeof(IReadOnlyCollection)); - model.IsRemoteStream.ShouldBeTrue(); + model.IsRemoteStream.ShouldBeFalse(); } [Fact] @@ -128,9 +128,6 @@ public class ReturnValueApiDescriptionModel_IsRemoteStream_Tests [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(); } @@ -145,7 +142,6 @@ public class ReturnValueApiDescriptionModel_IsRemoteStream_Tests [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(); } @@ -189,7 +185,6 @@ 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", @@ -238,7 +233,6 @@ public class ReturnValueApiDescriptionModel_BackwardsCompat_Tests 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 }); From f057009ab7d23530cad119d500ef2c5a7fd64c8a Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 18:24:23 +0800 Subject: [PATCH 10/16] Echo declared Accept media types in jQuery proxy generator --- .../JQuery/JQueryProxyScriptGenerator.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs index 4502598c75..07c480e8c7 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs @@ -146,10 +146,6 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen private static string GetJQueryDataTypeAndAcceptOverride(ActionApiDescriptionModel action) { - // jQuery doesn't natively support binary downloads, so don't override - // dataType/Accept for remote stream returns — letting the server pick the - // formatter naturally avoids forcing JSON metadata in place of the binary - // payload (which would re-introduce the original IRemoteStreamContent bug). if (action.ReturnValue.IsRemoteStream) { return string.Empty; @@ -162,15 +158,18 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen { var normalized = contentTypes.Select(NormalizeMediaType).ToList(); - if (normalized.Any(IsJsonMediaType)) + var firstJsonShaped = normalized.FirstOrDefault(IsJsonMediaType); + if (firstJsonShaped != null) { - return "{ dataType: 'json', headers: { Accept: 'application/json' } }, "; + return "{ dataType: 'json', headers: { Accept: '" + firstJsonShaped + "' } }, "; } if (normalized.All(ct => ct.StartsWith("text/", StringComparison.OrdinalIgnoreCase))) { - return "{ dataType: 'text', headers: { Accept: 'text/plain' } }, "; + return "{ dataType: 'text', headers: { Accept: '" + normalized[0] + "' } }, "; } + + return "{ headers: { Accept: '" + normalized[0] + "' } }, "; } return isStringReturn ? "{ dataType: 'text' }, " : string.Empty; @@ -190,7 +189,7 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen return string.Empty; } var semi = mediaType.IndexOf(';'); - return (semi < 0 ? mediaType : mediaType.Substring(0, semi)).Trim(); + return (semi < 0 ? mediaType : mediaType.Substring(0, semi)).Trim().ToLowerInvariant(); } private static string FindBestApiVersion(ActionApiDescriptionModel action) From 49c4ff5a05895b91f89b7fad94d49a164e737cc3 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 18:24:23 +0800 Subject: [PATCH 11/16] Unwrap ABP error envelope from Blob body and validationErrors-only payloads in RestService --- .../core/src/lib/services/rest.service.ts | 60 ++++++++++++--- .../core/src/lib/tests/rest.service.spec.ts | 75 ++++++++++++++++++- 2 files changed, 123 insertions(+), 12 deletions(-) diff --git a/npm/ng-packs/packages/core/src/lib/services/rest.service.ts b/npm/ng-packs/packages/core/src/lib/services/rest.service.ts index 65a98d15f1..4121eaa75a 100644 --- a/npm/ng-packs/packages/core/src/lib/services/rest.service.ts +++ b/npm/ng-packs/packages/core/src/lib/services/rest.service.ts @@ -1,7 +1,7 @@ import { HttpClient, HttpHeaders, HttpParameterCodec, HttpParams, HttpRequest } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { Observable, throwError } from 'rxjs'; -import { catchError } from 'rxjs/operators'; +import { Observable, from, of, throwError } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; import { ExternalHttpClient } from '../clients/http.client'; import { ABP } from '../models/common'; import { Rest } from '../models/rest'; @@ -57,10 +57,12 @@ export class RestService { } as any) .pipe( catchError(err => { - if (!skipHandleError) { - this.tryUnwrapJsonErrorBody(err, effectiveResponseType); + if (skipHandleError) { + return throwError(() => err); } - return skipHandleError ? throwError(() => err) : this.handleError(err); + return this.normalizeErrorBody(err, effectiveResponseType).pipe( + switchMap(normalizedErr => this.handleError(normalizedErr)), + ); }), ); } @@ -97,12 +99,46 @@ export class RestService { } } - private tryUnwrapJsonErrorBody(err: any, responseType: Rest.ResponseType): void { - if (responseType === Rest.ResponseType.JSON || !err) return; - if (typeof err.error !== 'string' || err.error.length === 0) return; + private normalizeErrorBody(err: any, responseType: Rest.ResponseType): Observable { + if (!err || responseType === Rest.ResponseType.JSON) { + return of(err); + } + + if (typeof err.error === 'string') { + this.tryParseJsonErrorText(err, err.error); + return of(err); + } + + if (typeof Blob !== 'undefined' && err.error instanceof Blob) { + return from(this.readBlobAsText(err.error)).pipe( + map((text: string) => { + this.tryParseJsonErrorText(err, text); + return err; + }), + catchError(() => of(err)), + ); + } + + return of(err); + } + + private readBlobAsText(blob: Blob): Promise { + if (typeof (blob as any).text === 'function') { + return (blob as any).text(); + } + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); + reader.onerror = () => reject(reader.error); + reader.readAsText(blob); + }); + } + + private tryParseJsonErrorText(err: any, text: string): void { + if (!text || text.length === 0) return; let parsed: any; try { - parsed = JSON.parse(err.error); + parsed = JSON.parse(text); } catch { return; } @@ -115,7 +151,11 @@ export class RestService { if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false; const inner = parsed.error; if (!inner || typeof inner !== 'object') return false; - return typeof inner.code === 'string' || typeof inner.message === 'string'; + return ( + typeof inner.code === 'string' || + typeof inner.message === 'string' || + Array.isArray(inner.validationErrors) + ); } private getParams(params: Rest.Params, encoder?: HttpParameterCodec): HttpParams { diff --git a/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts index cf967a1d47..3009b0d6dc 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts @@ -278,6 +278,34 @@ describe('HttpClient testing', () => { await completion; }); + test('should unwrap ABP envelope that carries only validationErrors (no message / code)', async () => { + const completion = new Promise((resolve, reject) => { + spectator.service + .request({ method: HttpMethod.GET, url: '/text' }, { responseType: Rest.ResponseType.Text }) + .pipe( + catchError(err => { + try { + expect(typeof err.error).toBe('object'); + expect(err.error.error.validationErrors).toHaveLength(1); + resolve(); + } catch (e) { + reject(e); + } + return of(null); + }), + ) + .subscribe(); + }); + + const req = spectator.expectOne(api + '/text', HttpMethod.GET); + req.flush( + '{"error":{"validationErrors":[{"message":"Required","members":["Email"]}]}}', + { status: 400, statusText: 'Bad Request' }, + ); + + await completion; + }); + test('should leave non-ABP-envelope JSON body alone in text mode', async () => { const completion = new Promise((resolve, reject) => { spectator.service @@ -285,7 +313,6 @@ describe('HttpClient testing', () => { .pipe( catchError(err => { try { - // JSON parses fine but no error.code/error.message → keep raw string expect(typeof err.error).toBe('string'); expect(err.error).toBe('{"foo":"bar"}'); resolve(); @@ -340,6 +367,51 @@ describe('HttpClient testing', () => { await completion; }); + test('should unwrap ABP error envelope from a Blob body in blob mode', async () => { + // HttpTestingController doesn't deliver Blob in HttpErrorResponse; call the helper directly. + const err: any = { + error: new Blob( + ['{"error":{"code":"AbpAuthorization.002","message":"forbidden-blob"}}'], + { type: 'application/json' }, + ), + }; + + const normalized: any = await (spectator.service as any) + .normalizeErrorBody(err, Rest.ResponseType.Blob) + .toPromise(); + + expect(normalized.error).toEqual({ + error: { code: 'AbpAuthorization.002', message: 'forbidden-blob' }, + }); + }); + + test('should leave non-JSON Blob body alone in blob mode', async () => { + const blob = new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type: 'image/jpeg' }); + const err: any = { error: blob }; + + const normalized: any = await (spectator.service as any) + .normalizeErrorBody(err, Rest.ResponseType.Blob) + .toPromise(); + + expect(normalized.error).toBe(blob); + }); + + test('should swallow Blob.text() rejection and keep the original error in blob mode', async () => { + const fakeBlob = { + text: () => Promise.reject(new Error('boom')), + } as unknown as Blob; + const err: any = { error: fakeBlob, message: 'original' }; + + Object.setPrototypeOf(fakeBlob, Blob.prototype); + + const normalized: any = await (spectator.service as any) + .normalizeErrorBody(err, Rest.ResponseType.Blob) + .toPromise(); + + expect(normalized).toBe(err); + expect(normalized.error).toBe(fakeBlob); + }); + test('should leave non-JSON error body alone in text mode', async () => { const spy = vi.spyOn(httpErrorReporter, 'reportError'); @@ -400,7 +472,6 @@ describe('HttpClient testing', () => { catchError(() => { try { const errArg: any = spy.mock.calls[0][0]; - // Angular HttpClient already parsed JSON in json mode → err.error is object expect(errArg.error).toEqual({ error: { code: 'X', message: 'y' } }); resolve(); } catch (e) { From b0e4a9477fa87bc6597a236ff37e8d12fdb9ec1c Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 18:24:24 +0800 Subject: [PATCH 12/16] Echo declared Accept media types and degrade IRemoteStreamContent collections to any[] in Angular schematic --- .../packages/schematics/src/models/method.ts | 3 + .../src/tests/action-to-body-mapper.spec.ts | 67 ++++++++++++++----- .../proxy-service-template-render.spec.ts | 14 ++++ .../packages/schematics/src/utils/service.ts | 32 +++++---- 4 files changed, 86 insertions(+), 30 deletions(-) diff --git a/npm/ng-packs/packages/schematics/src/models/method.ts b/npm/ng-packs/packages/schematics/src/models/method.ts index 920bddba0f..c160359770 100644 --- a/npm/ng-packs/packages/schematics/src/models/method.ts +++ b/npm/ng-packs/packages/schematics/src/models/method.ts @@ -87,6 +87,9 @@ export class Body { } isBlobMethod() { + if (this.httpResponseType === 'blob') { + return true; + } return VOLO_REMOTE_STREAM_CONTENT.some(x => x === this.responseTypeWithNamespace); } diff --git a/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts b/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts index 83984e7a4b..d6a80d7230 100644 --- a/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts +++ b/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts @@ -90,7 +90,7 @@ describe('createActionToBodyMapper — IRemoteStreamContent return value', () => expect(body.isBlobMethod()).toBe(true); }); - test('binary-only contentTypes also picks blob + octet-stream', () => { + test('binary-only contentTypes picks blob and echoes back the actual binary media type', () => { const body = mapBody( buildAction({ returnValue: { @@ -102,7 +102,7 @@ describe('createActionToBodyMapper — IRemoteStreamContent return value', () => ); expect(body.httpResponseType).toBe('blob'); - expect(body.acceptHeader).toBe('application/octet-stream'); + expect(body.acceptHeader).toBe('application/pdf'); }); }); @@ -172,6 +172,40 @@ describe('createActionToBodyMapper — IsRemoteStream backend flag', () => { expect(body.httpResponseType).toBe('blob'); expect(body.acceptHeader).toBe('application/octet-stream'); + expect(body.isBlobMethod()).toBe(true); + }); + + test('[Volo.Abp.Content.IRemoteStreamContent] (real ABP square-bracket form) must NOT pick blob and degrades responseType to any[]', () => { + // ABP serialises collections as `[T]` (not `T[]`) — pin the on-the-wire shape. + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.Collections.Generic.IList', + typeSimple: '[Volo.Abp.Content.IRemoteStreamContent]', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + expect(body.isBlobMethod()).toBe(false); + expect(body.responseType).toBe('any[]'); + }); + + test('IRemoteStreamContent[] array return must NOT pick blob (server falls back to JSON metadata)', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.IRemoteStreamContent[]', + typeSimple: 'Volo.Abp.Content.IRemoteStreamContent[]', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + expect(body.isBlobMethod()).toBe(false); + expect(body.responseType).toBe('any[]'); }); test('isRemoteStream=false with stream-content type-name still detected by type name (legacy)', () => { @@ -192,7 +226,7 @@ describe('createActionToBodyMapper — IsRemoteStream backend flag', () => { describe('createActionToBodyMapper — +json suffix detection', () => { const mapBody = createActionToBodyMapper(); - test('application/problem+json picked as json', () => { + test('application/problem+json echoes back as Accept (decoder stays json)', () => { const body = mapBody( buildAction({ returnValue: { @@ -204,10 +238,10 @@ describe('createActionToBodyMapper — +json suffix detection', () => { ); expect(body.httpResponseType).toBe('json'); - expect(body.acceptHeader).toBe('application/json'); + expect(body.acceptHeader).toBe('application/problem+json'); }); - test('text/json picked as json (informal but real-world)', () => { + test('text/json echoes back as text/json (decoder stays json)', () => { const body = mapBody( buildAction({ returnValue: { @@ -219,10 +253,10 @@ describe('createActionToBodyMapper — +json suffix detection', () => { ); expect(body.httpResponseType).toBe('json'); - expect(body.acceptHeader).toBe('application/json'); + expect(body.acceptHeader).toBe('text/json'); }); - test('application/vnd.api+json picked as json', () => { + test('application/vnd.api+json echoes back as Accept (decoder stays json)', () => { const body = mapBody( buildAction({ returnValue: { @@ -234,6 +268,7 @@ describe('createActionToBodyMapper — +json suffix detection', () => { ); expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/vnd.api+json'); }); }); @@ -287,7 +322,7 @@ describe('createActionToBodyMapper — contentTypes precedence and edge cases', expect(body.acceptHeader).toBe('application/octet-stream'); }); - test('IRemoteStreamContent[] array also picked as blob', () => { + test('IRemoteStreamContent[] array falls through to defaults (server returns JSON metadata, not binary)', () => { const body = mapBody( buildAction({ returnValue: { @@ -297,8 +332,8 @@ describe('createActionToBodyMapper — contentTypes precedence and edge cases', } as Partial), ); - expect(body.httpResponseType).toBe('blob'); - expect(body.acceptHeader).toBe('application/octet-stream'); + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); }); test('case-insensitive json detection (APPLICATION/JSON)', () => { @@ -316,7 +351,7 @@ describe('createActionToBodyMapper — contentTypes precedence and edge cases', expect(body.acceptHeader).toBe('application/json'); }); - test('image/* contentTypes alone picks blob', () => { + test('image/* contentTypes alone picks blob and echoes back the first image type', () => { const body = mapBody( buildAction({ returnValue: { @@ -328,7 +363,7 @@ describe('createActionToBodyMapper — contentTypes precedence and edge cases', ); expect(body.httpResponseType).toBe('blob'); - expect(body.acceptHeader).toBe('application/octet-stream'); + expect(body.acceptHeader).toBe('image/png'); }); test('video/* and audio/* picked as blob', () => { @@ -370,7 +405,7 @@ describe('createActionToBodyMapper — contentTypes precedence and edge cases', expect(body.acceptHeader).toBeUndefined(); }); - test('mixed text/* and application/json picks json', () => { + test('mixed text/* and application/json picks json and echoes the first json-shaped media type', () => { const body = mapBody( buildAction({ returnValue: { @@ -382,7 +417,7 @@ describe('createActionToBodyMapper — contentTypes precedence and edge cases', ); expect(body.httpResponseType).toBe('json'); - expect(body.acceptHeader).toBe('application/json'); + expect(body.acceptHeader).toBe('text/json'); }); test('contentTypes with json-suffix variants still picks json', () => { @@ -458,7 +493,7 @@ describe('createActionToBodyMapper — contentTypes precedence and edge cases', expect(body.httpResponseType).toBe('json'); }); - test('text/csv only (custom text format) picks text', () => { + test('text/csv only (custom text format) picks text and echoes the content type back', () => { const body = mapBody( buildAction({ returnValue: { @@ -470,7 +505,7 @@ describe('createActionToBodyMapper — contentTypes precedence and edge cases', ); expect(body.httpResponseType).toBe('text'); - expect(body.acceptHeader).toBe('text/plain'); + expect(body.acceptHeader).toBe('text/csv'); }); }); diff --git a/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts b/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts index fb611c0088..87edc87b4f 100644 --- a/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts +++ b/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts @@ -138,6 +138,20 @@ describe('proxy service template — rendered output', () => { expect(output).toContain("headers: { Accept: 'application/octet-stream' }"); }); + test('IRemoteStreamContent[] degradation renders any[] return type and does not reference IRemoteStreamContent', () => { + const output = render(buildContext({ + responseType: 'any[]', + responseTypeWithNamespace: '[Volo.Abp.Content.IRemoteStreamContent]', + isBlobMethod: () => false, + httpResponseType: undefined, + acceptHeader: undefined, + })); + + expect(output).toContain('any[]'); + expect(output).not.toContain('IRemoteStreamContent'); + expect(output).not.toContain("responseType: 'blob'"); + }); + test('arraybuffer httpResponseType emits responseType', () => { const output = render(buildContext({ responseType: 'ArrayBuffer', diff --git a/npm/ng-packs/packages/schematics/src/utils/service.ts b/npm/ng-packs/packages/schematics/src/utils/service.ts index c0cc4b0cbc..d5d65326d9 100644 --- a/npm/ng-packs/packages/schematics/src/utils/service.ts +++ b/npm/ng-packs/packages/schematics/src/utils/service.ts @@ -96,6 +96,9 @@ export function createActionToBodyMapper() { responseType = adaptType(normalizedType); } } + if (isRemoteStreamContentArray(returnValue.typeSimple)) { + responseType = 'any[]'; + } const responseTypeWithNamespace = returnValue.typeSimple; const { httpResponseType, acceptHeader } = resolveHttpResponseAndAccept( responseType, @@ -175,28 +178,24 @@ function resolveHttpResponseAndAccept( contentTypes: string[] | undefined, isRemoteStreamFlag: boolean | undefined, ): { httpResponseType?: 'json' | 'text' | 'blob' | 'arraybuffer'; acceptHeader?: string } { - if ( - isRemoteStreamFlag || - isRemoteStreamContent(responseTypeWithNamespace) || - isRemoteStreamContentArray(responseTypeWithNamespace) - ) { + if (isRemoteStreamFlag || isRemoteStreamContent(responseTypeWithNamespace)) { return { httpResponseType: 'blob', acceptHeader: 'application/octet-stream' }; } if (contentTypes && contentTypes.length > 0) { const normalized = contentTypes.map(normalizeMediaType); - if (normalized.some(isJsonMediaType)) { - return { httpResponseType: 'json', acceptHeader: 'application/json' }; + const firstJsonShaped = normalized.find(isJsonMediaType); + if (firstJsonShaped) { + return { httpResponseType: 'json', acceptHeader: firstJsonShaped }; } - if (normalized.every(ct => ct.startsWith('text/'))) { - return { httpResponseType: 'text', acceptHeader: 'text/plain' }; + return { httpResponseType: 'text', acceptHeader: normalized[0] }; } - if (normalized.every(isBinaryMediaType)) { - return { httpResponseType: 'blob', acceptHeader: 'application/octet-stream' }; + return { httpResponseType: 'blob', acceptHeader: normalized[0] }; } + return { acceptHeader: normalized[0] }; } if (responseType === 'string') { @@ -247,13 +246,18 @@ export function isRemoteStreamContent(type: string) { } export function isRemoteStreamContentArray(type: string) { - // Check for array types like Volo.Abp.Content.IRemoteStreamContent[] if (VOLO_REMOTE_STREAM_CONTENT.map(x => `${x}[]`).some(x => x === type)) { return true; } - // Check for collection types like List, IEnumerable, ICollection, Collection, IList - // This matches any generic type from System.Collections.Generic that implements IEnumerable + // ABP serialises collections as `[T]` (see ApiTypeNameHelper.GetSimpleTypeName). + if (type.startsWith('[') && type.endsWith(']')) { + const inner = type.slice(1, -1); + if (VOLO_REMOTE_STREAM_CONTENT.includes(inner)) { + return true; + } + } + if (isCollectionType(type)) { const { generics } = extractGenerics(type); if (generics.length > 0 && VOLO_REMOTE_STREAM_CONTENT.includes(generics[0])) { From 025e495d6eec22f6d3420857eb5eae18e7f93833 Mon Sep 17 00:00:00 2001 From: maliming Date: Tue, 16 Jun 2026 20:02:14 +0800 Subject: [PATCH 13/16] Upgrade proxy service template test to ts.createProgram for real semantic checks --- .../proxy-service-template-render.spec.ts | 116 ++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts b/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts index 87edc87b4f..f8eb80143c 100644 --- a/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts +++ b/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts @@ -190,4 +190,120 @@ describe('proxy service template — rendered output', () => { expect(output).toContain('this.restService.request true } }, + { name: 'any[] degradation', body: { responseType: 'any[]', responseTypeWithNamespace: '[Volo.Abp.Content.IRemoteStreamContent]' } }, + { name: 'xml Accept only', body: { responseType: 'any', responseTypeWithNamespace: 'any', acceptHeader: 'application/xml' } }, + ])('rendered service compiles cleanly under real ts.Program ($name)', ({ body }) => { + // ts.transpileModule is a single-file transform; it can't resolve @abp/ng.core + // imports. Real ts.createProgram with a host that supplies a minimal .d.ts stub + // for @abp/ng.core exercises the actual semantic checker — closer to ng build + // without the cost of a full Angular workspace. + const ts = require('typescript'); + const ctx = buildContext(body as Partial); + // Mirror what the real schematic emits: every action gets a trailing + // `config?: Partial` parameter — without it the rendered call + // site references a `config` identifier that's never declared. + // Use a permissive type so strict-mode spread doesn't trip on Partial<{...}> + // semantics; the real schematic uses `Partial` but the spread + // pattern is identical and we want to assert template *shape*, not the + // exact inference Angular's strict mode does for the upstream type. + ctx.methods[0].signature.parameters = [{ name: 'config', type: 'Record' } as any]; + const output = render(ctx); + + const abpStub = ` + declare module '@abp/ng.core' { + export namespace Rest { + export interface Config { + apiName?: string; + observe?: any; + skipHandleError?: boolean; + responseType?: string; + [key: string]: any; + } + export type Observe = any; + } + export class RestService { + request(req: any, config?: any): import('rxjs').Observable; + } + } + `; + const domStub = 'declare class Blob { constructor(parts?: any[], options?: any); }\n'; + const angularCoreStub = ` + declare module '@angular/core' { + export function Injectable(opts?: any): ClassDecorator; + // Mirror Angular's overload that accepts a class token and returns its instance. + export function inject(token: { new (...args: any[]): T }): T; + export function inject(token: any): T; + } + `; + const rxjsStub = ` + declare module 'rxjs' { + export class Observable { subscribe(...args: any[]): unknown; } + } + `; + + // Bundle the @abp/ng.core / @angular/core / rxjs ambient declarations into + // a single .d.ts root file so TS sees them as ambient module declarations. + const ambient = abpStub + angularCoreStub + rxjsStub + domStub; + const sources: Record = { + '/proxy/sample.service.ts': output, + '/proxy/ambient.d.ts': ambient, + }; + + const compilerOptions: any = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.ES2020, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + experimentalDecorators: true, + emitDecoratorMetadata: true, + strict: true, + noEmit: true, + skipLibCheck: true, + }; + + const baseHost = ts.createCompilerHost(compilerOptions, true); + const host: any = { + ...baseHost, + getSourceFile: (fileName: string, languageVersion: any, onError: any) => { + if (sources[fileName]) { + return ts.createSourceFile(fileName, sources[fileName], languageVersion, true); + } + return baseHost.getSourceFile(fileName, languageVersion, onError); + }, + fileExists: (fileName: string) => + sources[fileName] != null || baseHost.fileExists(fileName), + readFile: (fileName: string) => + sources[fileName] ?? baseHost.readFile(fileName), + }; + + const program = ts.createProgram(Object.keys(sources), compilerOptions, host); + const errors = ts + .getPreEmitDiagnostics(program) + .filter((d: any) => + d.category === ts.DiagnosticCategory.Error && + // Ignore TS6053 "File 'lib.x' not found" which is unrelated to our generated code. + d.code !== 6053, + ); + + if (errors.length) { + const messages = errors + .map((d: any) => { + const where = d.file + ? (() => { + const p = d.file.getLineAndCharacterOfPosition(d.start ?? 0); + const lineText = d.file.text.split('\n')[p.line]; + return `${d.file.fileName}:${p.line + 1}:${p.character + 1}\n>>> ${lineText}\n>>> ${' '.repeat(p.character)}^`; + })() + : '(no file)'; + return `[${where}] TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`; + }) + .join('\n---\n'); + throw new Error(`Generated proxy did not compile:\n${output}\n=== diagnostics ===\n${messages}`); + } + expect(errors).toHaveLength(0); + }); }); From b39aa93d37812132ed0b9f6039b286c2f08144e8 Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 17 Jun 2026 12:23:43 +0800 Subject: [PATCH 14/16] Forward IRemoteStreamContent DTO uploads as multipart in jQuery and Angular client proxies --- .../JQuery/JQueryProxyScriptGenerator.cs | 17 +- ...oxyRequestPayloadBuilder_FormData_Tests.cs | 804 ++++++++++++++++++ ...ProxyScriptGenerator_ContentTypes_Tests.cs | 113 ++- .../schematics/src/enums/binding-source-id.ts | 1 + .../src/tests/action-to-body-mapper.spec.ts | 128 +++ .../proxy-service-template-render.spec.ts | 216 ++++- .../packages/schematics/src/utils/service.ts | 31 +- 7 files changed, 1287 insertions(+), 23 deletions(-) create mode 100644 framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyRequestPayloadBuilder_FormData_Tests.cs diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs index 07c480e8c7..8e15d85191 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs @@ -135,7 +135,8 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen AddAjaxCallParameters(script, action); - var ajaxParamsIsFromForm = action.Parameters.Any(x => x.BindingSourceId == ParameterBindingSources.Form); + var hasFormFile = action.Parameters.Any(x => x.BindingSourceId == ParameterBindingSources.FormFile); + var ajaxParamsIsFromForm = !hasFormFile && action.Parameters.Any(x => x.BindingSourceId == ParameterBindingSources.Form); var dataType = GetJQueryDataTypeAndAcceptOverride(action); script.AppendLine(ajaxParamsIsFromForm ? " }, $.extend(true, {}, " + dataType + "{ contentType: 'application/x-www-form-urlencoded; charset=UTF-8' }, ajaxParams)));" @@ -230,6 +231,20 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen script.Append(" headers: " + headers); } + var firstFileParam = action.Parameters.FirstOrDefault(p => p.BindingSourceId == ParameterBindingSources.FormFile); + if (firstFileParam != null) + { + var fileVar = ProxyScriptingJsFuncHelper.NormalizeJsVariableName(firstFileParam.NameOnMethod.ToCamelCase()); + script.AppendLine(","); + script.Append(" data: " + fileVar + ","); + script.AppendLine(); + script.Append(" processData: false,"); + script.AppendLine(); + script.Append(" contentType: false"); + script.AppendLine(); + return; + } + var body = ProxyScriptingHelper.GenerateBody(action); if (!body.IsNullOrEmpty()) { diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyRequestPayloadBuilder_FormData_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyRequestPayloadBuilder_FormData_Tests.cs new file mode 100644 index 0000000000..12b453f4f4 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyRequestPayloadBuilder_FormData_Tests.cs @@ -0,0 +1,804 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Content; +using Volo.Abp.Http.Modeling; +using Volo.Abp.Http.ProxyScripting.Generators; +using Volo.Abp.Json; +using Volo.Abp.Timing; +using Xunit; +using MicrosoftOptions = Microsoft.Extensions.Options.Options; + +namespace Volo.Abp.Http.Client.ClientProxying; + +public class ClientProxyRequestPayloadBuilder_FormData_Tests +{ + private readonly ClientProxyRequestPayloadBuilder _builder; + private readonly IJsonSerializer _jsonSerializer = new StubJsonSerializer(); + private static readonly ApiVersionInfo NoApiVersion = new("Query", "1.0"); + + public ClientProxyRequestPayloadBuilder_FormData_Tests() + { + var services = new ServiceCollection(); + var scopeFactory = services.BuildServiceProvider().GetRequiredService(); + var options = MicrosoftOptions.Create(new AbpHttpClientProxyingOptions()); + _builder = new ClientProxyRequestPayloadBuilder(scopeFactory, options, new TestClock()); + } + + [Fact] + public async Task Direct_IRemoteStreamContent_Param_Should_Produce_Single_StreamContent_Part() + { + var action = BuildAction(parameters: new[] + { + FormFileParam(name: "file", nameOnMethod: "file"), + }); + var stream = MakeStream("hello-direct"); + var args = new Dictionary + { + ["file"] = new RemoteStreamContent(stream, "demo.txt", "text/plain"), + }; + + var content = await InvokeAsync(action, args); + + var multipart = content.ShouldBeOfType(); + var parts = multipart.ToList(); + parts.Count.ShouldBe(1); + parts[0].Headers.ContentType!.MediaType.ShouldBe("text/plain"); + (await parts[0].ReadAsStringAsync()).ShouldBe("hello-direct"); + } + + [Fact] + public async Task Dto_With_IRemoteStreamContent_Property_Should_Flatten_To_Name_Plus_File_Parts() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestUploadDto + { + Name = "Alice", + File = new RemoteStreamContent(MakeStream("hello-single"), "single.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var multipart = content.ShouldBeOfType(); + var parts = multipart.ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Name", "Alice"); + await AssertStreamPart(parts[1], "File", "hello-single", "text/plain"); + } + + [Fact] + public async Task Dto_With_IEnumerable_IRemoteStreamContent_Should_Emit_One_Part_Per_Stream() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Label", nameOnMethod: "input"), + FormFileParam(name: "Files", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestUploadFilesDto + { + Label = "batch", + Files = new[] + { + new RemoteStreamContent(MakeStream("a-content"), "a.txt", "text/plain"), + new RemoteStreamContent(MakeStream("b-content"), "b.txt", "text/csv"), + }, + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(3); + await AssertStringPart(parts[0], "Label", "batch"); + await AssertStreamPart(parts[1], "Files", "a-content", "text/plain"); + await AssertStreamPart(parts[2], "Files", "b-content", "text/csv"); + } + + [Fact] + public async Task Nested_Dto_With_Child_File_Path_Should_Be_Reflected_Via_Dotted_Name() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Outer", nameOnMethod: "input"), + FormFileParam(name: "Child.File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestNestedUploadDto + { + Outer = "outerVal", + Child = new TestNestedChildDto + { + File = new RemoteStreamContent(MakeStream("hello-nested"), "nested.txt", "text/plain"), + }, + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Outer", "outerVal"); + await AssertStreamPart(parts[1], "Child.File", "hello-nested", "text/plain"); + } + + [Fact] + public async Task Form_Only_Action_Without_FormFile_Should_Still_Produce_Multipart_With_String_Parts() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormParam(name: "Tag", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestUploadDto { Name = "Alice", Tag = "T1" }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Name", "Alice"); + await AssertStringPart(parts[1], "Tag", "T1"); + } + + [Fact] + public async Task Body_Binding_Wins_Over_Form_And_Returns_Json_StringContent() + { + var action = BuildAction(parameters: new[] + { + new ParameterApiDescriptionModel + { + Name = "input", + NameOnMethod = "input", + Type = typeof(TestUploadDto).FullName!, + TypeSimple = "dto", + BindingSourceId = ParameterBindingSources.Body, + }, + }); + var args = new Dictionary + { + ["input"] = new TestUploadDto { Name = "Alice" }, + }; + + var content = await InvokeAsync(action, args); + + var stringContent = content.ShouldBeOfType(); + stringContent.Headers.ContentType!.MediaType.ShouldBe("application/json"); + var body = await stringContent.ReadAsStringAsync(); + body.ShouldContain("\"Name\":\"Alice\""); + } + + [Fact] + public async Task No_Form_Or_Body_Params_Should_Return_Null_Content() + { + var action = BuildAction(parameters: new[] + { + new ParameterApiDescriptionModel + { + Name = "id", + NameOnMethod = "id", + Type = "System.Int32", + TypeSimple = "int", + BindingSourceId = ParameterBindingSources.Path, + }, + }); + var args = new Dictionary { ["id"] = 42 }; + + var content = await InvokeAsync(action, args); + + content.ShouldBeNull(); + } + + [Fact] + public async Task FormFile_With_Null_Value_Should_Be_Skipped_Not_Throw() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestUploadDto { Name = "Alice", File = null }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(1); + await AssertStringPart(parts[0], "Name", "Alice"); + } + + [Fact] + public async Task Three_Level_Nested_Dto_Should_Resolve_Outer_Inner_File_Via_Dotted_Path() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Outer", nameOnMethod: "input"), + FormFileParam(name: "Inner.Child.File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestThreeLevelDto + { + Outer = "outerVal", + Inner = new TestThreeLevelMiddleDto + { + Child = new TestNestedChildDto + { + File = new RemoteStreamContent(MakeStream("hello-3-levels"), "deep.txt", "text/plain"), + }, + }, + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Outer", "outerVal"); + await AssertStreamPart(parts[1], "Inner.Child.File", "hello-3-levels", "text/plain"); + } + + [Fact] + public async Task File_With_UTF8_FileName_Should_Survive_To_Content_Disposition_FileName_Star() + { + var action = BuildAction(parameters: new[] + { + FormFileParam(name: "file", nameOnMethod: "file"), + }); + var args = new Dictionary + { + ["file"] = new RemoteStreamContent(MakeStream("hello-utf8"), "中文-文件名.txt", "text/plain"), + }; + + var content = await InvokeAsync(action, args); + + var part = content.ShouldBeOfType().Single(); + var disposition = part.Headers.ContentDisposition; + disposition.ShouldNotBeNull(); + disposition!.FileNameStar.ShouldBe("中文-文件名.txt"); + (await part.ReadAsStringAsync()).ShouldBe("hello-utf8"); + } + + [Fact] + public async Task Dto_Treated_As_Body_When_Not_Registered_Should_Serialize_As_Json_With_File_Field_Embedded() + { + var action = BuildAction(parameters: new[] + { + new ParameterApiDescriptionModel + { + Name = "input", + NameOnMethod = "input", + Type = typeof(TestUploadDto).FullName!, + TypeSimple = "dto", + BindingSourceId = ParameterBindingSources.Body, + }, + }); + var args = new Dictionary + { + ["input"] = new TestUploadDto + { + Name = "Alice", + File = new RemoteStreamContent(MakeStream("ignored"), "x.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var stringContent = content.ShouldBeOfType(); + stringContent.Headers.ContentType!.MediaType.ShouldBe("application/json"); + var body = await stringContent.ReadAsStringAsync(); + body.ShouldContain("\"Name\":\"Alice\""); + body.ShouldContain("\"File\""); + } + + [Fact] + public async Task Dto_With_Both_Stream_Property_And_Stream_Collection_Should_Emit_All_Parts() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Label", nameOnMethod: "input"), + FormFileParam(name: "Main", nameOnMethod: "input"), + FormFileParam(name: "Extras", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestMixedUploadDto + { + Label = "combo", + Main = new RemoteStreamContent(MakeStream("main-body"), "main.txt", "text/plain"), + Extras = new[] + { + new RemoteStreamContent(MakeStream("extra-a"), "ea.txt", "text/csv"), + new RemoteStreamContent(MakeStream("extra-b"), "eb.txt", "text/plain"), + }, + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(4); + await AssertStringPart(parts[0], "Label", "combo"); + await AssertStreamPart(parts[1], "Main", "main-body", "text/plain"); + await AssertStreamPart(parts[2], "Extras", "extra-a", "text/csv"); + await AssertStreamPart(parts[3], "Extras", "extra-b", "text/plain"); + } + + [Fact] + public async Task Two_Different_Upload_Actions_On_Same_Builder_Should_Not_Pollute_Each_Other() + { + var actionA = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var actionB = BuildAction(parameters: new[] + { + FormParam(name: "Label", nameOnMethod: "input"), + FormFileParam(name: "Files", nameOnMethod: "input"), + }); + + var argsA = new Dictionary + { + ["input"] = new TestUploadDto { Name = "A", File = new RemoteStreamContent(MakeStream("aa"), "a.txt", "text/plain") }, + }; + var argsB = new Dictionary + { + ["input"] = new TestUploadFilesDto + { + Label = "B", + Files = new[] { new RemoteStreamContent(MakeStream("bb"), "b.txt", "text/csv") }, + }, + }; + + var contentA = (await InvokeAsync(actionA, argsA)).ShouldBeOfType().ToList(); + var contentB = (await InvokeAsync(actionB, argsB)).ShouldBeOfType().ToList(); + + await AssertStringPart(contentA[0], "Name", "A"); + await AssertStreamPart(contentA[1], "File", "aa", "text/plain"); + await AssertStringPart(contentB[0], "Label", "B"); + await AssertStreamPart(contentB[1], "Files", "bb", "text/csv"); + } + + [Fact] + public async Task Inherited_Dto_With_File_Property_On_Base_Class_Should_Resolve_Via_Reflection() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + FormParam(name: "ChildOnly", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestInheritedUploadDto + { + Name = "inherited", + File = new RemoteStreamContent(MakeStream("from-base"), "base.txt", "text/plain"), + ChildOnly = "extra", + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(3); + await AssertStringPart(parts[0], "Name", "inherited"); + await AssertStreamPart(parts[1], "File", "from-base", "text/plain"); + await AssertStringPart(parts[2], "ChildOnly", "extra"); + } + + [Fact] + public async Task Record_Dto_With_File_Property_Should_Resolve_Via_Reflection() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestRecordUploadDto( + Name: "Diana", + File: new RemoteStreamContent(MakeStream("from-record"), "r.txt", "text/plain")), + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Name", "Diana"); + await AssertStreamPart(parts[1], "File", "from-record", "text/plain"); + } + + [Fact] + public async Task Dto_With_DateTime_Enum_And_Nullable_Struct_Fields_Should_Round_Trip_As_String_Parts() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "When", nameOnMethod: "input"), + FormParam(name: "Status", nameOnMethod: "input"), + FormParam(name: "Quantity", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestPrimitiveUploadDto + { + When = new DateTime(2025, 6, 1, 12, 34, 56, DateTimeKind.Utc), + Status = TestStatus.Active, + Quantity = 7, + File = new RemoteStreamContent(MakeStream("primitives"), "p.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(4); + (await parts[0].ReadAsStringAsync()).ShouldStartWith("2025-06-01T12:34:56"); + (await parts[1].ReadAsStringAsync()).ShouldBe("Active"); + (await parts[2].ReadAsStringAsync()).ShouldBe("7"); + await AssertStreamPart(parts[3], "File", "primitives", "text/plain"); + } + + [Fact] + public async Task Dto_With_Nullable_Struct_Field_Set_To_Null_Should_Skip_The_Part_Entirely() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Quantity", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestPrimitiveUploadDto + { + Quantity = null, + File = new RemoteStreamContent(MakeStream("null-qty"), "n.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(1); + await AssertStreamPart(parts[0], "File", "null-qty", "text/plain"); + } + + [Fact] + public async Task Polymorphic_Dto_With_Derived_Type_Should_Reflect_Properties_From_Concrete_Runtime_Type() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormParam(name: "ExtraField", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + TestUploadDtoBase polymorphic = new TestPolymorphicDerivedDto + { + Name = "poly", + ExtraField = "derived-only", + File = new RemoteStreamContent(MakeStream("from-derived"), "d.txt", "text/plain"), + }; + var args = new Dictionary { ["input"] = polymorphic }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(3); + await AssertStringPart(parts[0], "Name", "poly"); + await AssertStringPart(parts[1], "ExtraField", "derived-only"); + await AssertStreamPart(parts[2], "File", "from-derived", "text/plain"); + } + + [Fact] + public async Task Generic_Dto_Closed_Over_Concrete_Type_Should_Reflect_Open_Generic_Property() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Payload", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestGenericUploadDto + { + Payload = "closed-string", + File = new RemoteStreamContent(MakeStream("g.txt-body"), "g.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Payload", "closed-string"); + await AssertStreamPart(parts[1], "File", "g.txt-body", "text/plain"); + } + + [Fact] + public async Task Generic_Dto_With_Integer_Payload_Should_Convert_Via_ConvertValueToString() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Payload", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestGenericUploadDto + { + Payload = 42, + File = new RemoteStreamContent(MakeStream("int-payload"), "i.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Payload", "42"); + } + + [Fact] + public async Task Underlying_Caller_Stream_Is_Wrapped_Without_Buffering_So_Retry_Sees_Drained_Source() + { + // Pin pass-through behaviour: HttpClient retry that re-reads `sourceStream` + // observes an empty body (no internal buffering). Opting in to buffering + // later would double-allocate for large uploads. + var action = BuildAction(parameters: new[] + { + FormFileParam(name: "file", nameOnMethod: "file"), + }); + var sourceStream = MakeStream("retry-payload"); + var args = new Dictionary + { + ["file"] = new RemoteStreamContent(sourceStream, "r.txt", "text/plain"), + }; + + var content = await InvokeAsync(action, args); + + var part = content.ShouldBeOfType().Single(); + sourceStream.Position.ShouldBe(0); + + (await part.ReadAsStringAsync()).ShouldBe("retry-payload"); + + sourceStream.Position.ShouldBe(sourceStream.Length); + } + + [Fact] + public async Task Action_With_No_Parameters_Should_Return_Null_Content_Without_Throwing() + { + var action = BuildAction(parameters: Array.Empty()); + var args = new Dictionary(); + + var content = await InvokeAsync(action, args); + + content.ShouldBeNull(); + } + + [Fact] + public async Task Path_Plus_Form_Plus_FormFile_Should_Skip_Path_And_Emit_Multipart_Only() + { + var action = BuildAction(parameters: new[] + { + new ParameterApiDescriptionModel + { + Name = "id", + NameOnMethod = "id", + Type = "System.Int32", + TypeSimple = "int", + BindingSourceId = ParameterBindingSources.Path, + }, + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["id"] = 7, + ["input"] = new TestUploadDto + { + Name = "Bob", + File = new RemoteStreamContent(MakeStream("path-mixed"), "p.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Name", "Bob"); + await AssertStreamPart(parts[1], "File", "path-mixed", "text/plain"); + } + + private Task InvokeAsync(ActionApiDescriptionModel action, IReadOnlyDictionary args) + => _builder.BuildContentAsync(action, args, _jsonSerializer, NoApiVersion); + + private static ActionApiDescriptionModel BuildAction(ParameterApiDescriptionModel[] parameters) + => new() + { + UniqueName = "TestAction", + Name = "TestAction", + HttpMethod = "POST", + Url = "api/test", + SupportedVersions = new List(), + ParametersOnMethod = new List(), + Parameters = parameters.ToList(), + ReturnValue = new ReturnValueApiDescriptionModel + { + Type = "System.String", + TypeSimple = "string", + }, + AuthorizeDatas = new List(), + }; + + private static ParameterApiDescriptionModel FormParam(string name, string nameOnMethod) + => new() + { + Name = name, + NameOnMethod = nameOnMethod, + Type = "System.String", + TypeSimple = "string", + BindingSourceId = ParameterBindingSources.Form, + }; + + private static ParameterApiDescriptionModel FormFileParam(string name, string nameOnMethod) + => new() + { + Name = name, + NameOnMethod = nameOnMethod, + Type = typeof(IRemoteStreamContent).FullName!, + TypeSimple = "stream", + BindingSourceId = ParameterBindingSources.FormFile, + }; + + private static MemoryStream MakeStream(string text) + { + var ms = new MemoryStream(); + ms.Write(Encoding.UTF8.GetBytes(text)); + ms.Position = 0; + return ms; + } + + private static async Task AssertStringPart(HttpContent part, string expectedName, string expectedValue) + { + var disposition = part.Headers.ContentDisposition; + disposition.ShouldNotBeNull(); + disposition!.Name!.Trim('"').ShouldBe(expectedName); + (await part.ReadAsStringAsync()).ShouldBe(expectedValue); + } + + private static async Task AssertStreamPart(HttpContent part, string expectedName, string expectedBody, string expectedContentType) + { + var disposition = part.Headers.ContentDisposition; + disposition.ShouldNotBeNull(); + disposition!.Name!.Trim('"').ShouldBe(expectedName); + part.Headers.ContentType!.MediaType.ShouldBe(expectedContentType); + (await part.ReadAsStringAsync()).ShouldBe(expectedBody); + } + + private class TestUploadDto + { + public string? Name { get; set; } + public string? Tag { get; set; } + public IRemoteStreamContent? File { get; set; } + } + + private class TestUploadFilesDto + { + public string? Label { get; set; } + public IEnumerable? Files { get; set; } + } + + private class TestNestedUploadDto + { + public string? Outer { get; set; } + public TestNestedChildDto? Child { get; set; } + } + + private class TestNestedChildDto + { + public IRemoteStreamContent? File { get; set; } + } + + private class TestThreeLevelDto + { + public string? Outer { get; set; } + public TestThreeLevelMiddleDto? Inner { get; set; } + } + + private class TestThreeLevelMiddleDto + { + public TestNestedChildDto? Child { get; set; } + } + + private class TestMixedUploadDto + { + public string? Label { get; set; } + public IRemoteStreamContent? Main { get; set; } + public IEnumerable? Extras { get; set; } + } + + private class TestUploadDtoBase + { + public string? Name { get; set; } + public IRemoteStreamContent? File { get; set; } + } + + private class TestInheritedUploadDto : TestUploadDtoBase + { + public string? ChildOnly { get; set; } + } + + private record TestRecordUploadDto(string Name, IRemoteStreamContent File); + + private class TestPrimitiveUploadDto + { + public DateTime When { get; set; } + public TestStatus Status { get; set; } + public int? Quantity { get; set; } + public IRemoteStreamContent? File { get; set; } + } + + private enum TestStatus + { + Pending = 0, + Active = 1, + Done = 2, + } + + private class TestPolymorphicDerivedDto : TestUploadDtoBase + { + public string? ExtraField { get; set; } + } + + private class TestGenericUploadDto + { + public T? Payload { get; set; } + public IRemoteStreamContent? File { get; set; } + } + + private class StubJsonSerializer : IJsonSerializer + { + public string Serialize(object obj, bool camelCase = true, bool indented = false) + => System.Text.Json.JsonSerializer.Serialize(obj); + + public T Deserialize(string jsonString, bool camelCase = true) + => System.Text.Json.JsonSerializer.Deserialize(jsonString)!; + + public object Deserialize(Type type, string jsonString, bool camelCase = true) + => System.Text.Json.JsonSerializer.Deserialize(jsonString, type)!; + } + + private class TestClock : IClock + { + public DateTime Now => DateTime.UtcNow; + public DateTimeKind Kind => DateTimeKind.Utc; + public bool SupportsMultipleTimezone => false; + public DateTime Normalize(DateTime dateTime) => dateTime; + public DateTime ConvertToUserTime(DateTime utcDateTime) => utcDateTime; + public DateTimeOffset ConvertToUserTime(DateTimeOffset dateTimeOffset) => dateTimeOffset; + public DateTime ConvertToUtc(DateTime dateTime) => dateTime; + } +} diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs index 0eacd51628..f6704ea0bd 100644 --- a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs @@ -96,12 +96,8 @@ public class JQueryProxyScriptGenerator_ContentTypes_Tests [Fact] public void IsRemoteStream_Should_Skip_DataType_Override_To_Avoid_JSON_Metadata_Regression() { - // For IRemoteStreamContent returns the API definition still advertises - // application/json (server-side default formatter list). The generator - // MUST NOT force dataType:'json' + Accept:'application/json' for these — - // doing so makes the server JSON-serialise the IRemoteStreamContent object - // and re-introduces the original IRemoteStreamContent bug. jQuery doesn't - // natively support binary downloads, so we let the legacy behavior stand. + // IRemoteStreamContent: ABP advertises application/json in formatter list, but + // forcing dataType:'json' here would make the server JSON-serialise the stream. var model = BuildAppModel( returnType: "Volo.Abp.Content.IRemoteStreamContent", contentTypes: new[] { "text/plain", "application/json", "text/json" }, @@ -113,6 +109,64 @@ public class JQueryProxyScriptGenerator_ContentTypes_Tests script.ShouldNotContain("Accept: 'application/json'"); } + [Fact] + public void Multipart_Upload_Should_Emit_FormData_Body_With_ProcessData_And_ContentType_False() + { + var script = _generator.CreateScript(BuildUploadModel( + uploadParameters: new[] + { + ("Name", "input", ParameterBindingSources.Form), + ("File", "input", ParameterBindingSources.FormFile), + })); + + script.ShouldContain("data: input"); + script.ShouldContain("processData: false"); + script.ShouldContain("contentType: false"); + script.ShouldNotContain("contentType: 'application/x-www-form-urlencoded"); + } + + [Fact] + public void Multipart_Upload_Should_Use_NameOnMethod_As_Data_Variable() + { + var script = _generator.CreateScript(BuildUploadModel( + uploadParameters: new[] + { + ("file", "file", ParameterBindingSources.FormFile), + })); + + script.ShouldContain("data: file"); + script.ShouldContain("processData: false"); + script.ShouldContain("contentType: false"); + } + + [Fact] + public void Plain_Form_Action_Without_FormFile_Should_Still_Emit_UrlEncoded_ContentType() + { + var script = _generator.CreateScript(BuildUploadModel( + uploadParameters: new[] + { + ("Name", "input", ParameterBindingSources.Form), + })); + + script.ShouldContain("contentType: 'application/x-www-form-urlencoded; charset=UTF-8'"); + script.ShouldNotContain("processData: false"); + script.ShouldNotContain("contentType: false"); + } + + [Fact] + public void Multipart_Upload_Should_Skip_FormPostData_And_Body_Generation() + { + var script = _generator.CreateScript(BuildUploadModel( + uploadParameters: new[] + { + ("Name", "input", ParameterBindingSources.Form), + ("File", "input", ParameterBindingSources.FormFile), + })); + + script.ShouldNotContain("'Name=' + "); + script.ShouldNotContain("JSON.stringify"); + } + private static ApplicationApiDescriptionModel BuildAppModel(string returnType, IList? contentTypes, bool isRemoteStream = false) { var model = ApplicationApiDescriptionModel.Create(); @@ -147,4 +201,51 @@ public class JQueryProxyScriptGenerator_ContentTypes_Tests return model; } + + private static ApplicationApiDescriptionModel BuildUploadModel( + (string Name, string NameOnMethod, string BindingSourceId)[] uploadParameters) + { + var model = ApplicationApiDescriptionModel.Create(); + var module = model.GetOrAddModule("app", "Default"); + var controller = module.GetOrAddController( + name: "TestController", + groupName: null, + isRemoteService: true, + isIntegrationService: false, + apiVersion: null, + type: typeof(object)); + + var parameters = new List(); + foreach (var (name, nameOnMethod, binding) in uploadParameters) + { + parameters.Add(new ParameterApiDescriptionModel + { + Name = name, + NameOnMethod = nameOnMethod, + Type = "System.String", + TypeSimple = "string", + BindingSourceId = binding, + }); + } + + var action = new ActionApiDescriptionModel + { + UniqueName = "UploadAsync", + Name = "UploadAsync", + HttpMethod = "POST", + Url = "api/test/upload", + SupportedVersions = new List(), + ParametersOnMethod = new List(), + Parameters = parameters, + ReturnValue = new ReturnValueApiDescriptionModel + { + Type = "System.Void", + TypeSimple = "void", + }, + AuthorizeDatas = new List(), + }; + controller.AddAction("UploadAsync", action); + + return model; + } } diff --git a/npm/ng-packs/packages/schematics/src/enums/binding-source-id.ts b/npm/ng-packs/packages/schematics/src/enums/binding-source-id.ts index 308b8dfc32..91c1901e09 100644 --- a/npm/ng-packs/packages/schematics/src/enums/binding-source-id.ts +++ b/npm/ng-packs/packages/schematics/src/enums/binding-source-id.ts @@ -3,5 +3,6 @@ export enum eBindingSourceId { Model = 'ModelBinding', Path = 'Path', Query = 'Query', + Form = 'Form', FormFile = 'FormFile', } diff --git a/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts b/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts index d6a80d7230..7156759689 100644 --- a/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts +++ b/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts @@ -576,3 +576,131 @@ describe('proxy service template emission', () => { expect(template).toMatch(/httpResponseType\s*&&\s*httpResponseType\s*!==\s*'json'/); }); }); + +describe('createActionToBodyMapper — multipart FormData uploads', () => { + const mapBody = createActionToBodyMapper(); + + test('DTO with one IRemoteStreamContent property collapses to FormData body using the method arg name', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + parameters: [ + { name: 'Name', nameOnMethod: 'input', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Form } as any, + { name: 'File', nameOnMethod: 'input', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial), + ); + + expect(body.body).toBe('input'); + expect(body.params).toEqual([]); + }); + + test('DTO with IEnumerable collapses to FormData body using the method arg name', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + parameters: [ + { name: 'Label', nameOnMethod: 'input', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Form } as any, + { name: 'Files', nameOnMethod: 'input', type: 'System.Collections.Generic.IEnumerable', typeSimple: '[Volo.Abp.Content.IRemoteStreamContent]', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial), + ); + + expect(body.body).toBe('input'); + expect(body.params).toEqual([]); + }); + + test('nested DTO with IRemoteStreamContent collapses to FormData body using the method arg name', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + parameters: [ + { name: 'Outer', nameOnMethod: 'input', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Form } as any, + { name: 'Child.File', nameOnMethod: 'input', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial), + ); + + expect(body.body).toBe('input'); + expect(body.params).toEqual([]); + }); + + test('direct IRemoteStreamContent parameter uses its own method arg name as the FormData body', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + parameters: [ + { name: 'file', nameOnMethod: 'file', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial), + ); + + expect(body.body).toBe('file'); + }); + + test('Path + upload-DTO mix keeps the path parameter while binding the upload to FormData', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + url: 'api/upload/{id}', + parameters: [ + { name: 'id', nameOnMethod: 'id', type: 'System.Int32', typeSimple: 'number', bindingSourceId: eBindingSourceId.Path } as any, + { name: 'Name', nameOnMethod: 'input', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Form } as any, + { name: 'File', nameOnMethod: 'input', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial), + ); + + expect(body.body).toBe('input'); + expect(body.url).toBe("`/api/upload/${id}`"); + }); + + test('actions without FormFile parameters are not affected', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + parameters: [ + { name: 'Name', nameOnMethod: 'name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Body } as any, + ], + } as Partial), + ); + + expect(body.body).toBe('name'); + }); +}); + +describe('createActionToBodyMapper — multipart upload params regression', () => { + const mapBody = createActionToBodyMapper(); + + test('AppService convention ModelBinding non-file field sharing the upload arg is dropped from query params', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + url: 'api/app/proxy-demo-test/upload-single', + parameters: [ + { nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, + { nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial), + ); + expect(body.body).toBe('input'); + expect(body.params.join(',')).not.toContain('name'); + }); + + test('Path param on a separate method arg stays in URL even when sibling form fields share the upload arg', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + url: 'api/proxy-demo/media/upload-with-path/{id}', + parameters: [ + { nameOnMethod: 'id', name: 'id', type: 'System.Int32', typeSimple: 'int', bindingSourceId: eBindingSourceId.Path } as any, + { nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, + { nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial), + ); + expect(body.body).toBe('input'); + expect(body.url).toContain('${id}'); + expect(body.params.join(',')).not.toContain('name'); + }); +}); diff --git a/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts b/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts index f8eb80143c..04b070ae61 100644 --- a/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts +++ b/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts @@ -198,19 +198,8 @@ describe('proxy service template — rendered output', () => { { name: 'any[] degradation', body: { responseType: 'any[]', responseTypeWithNamespace: '[Volo.Abp.Content.IRemoteStreamContent]' } }, { name: 'xml Accept only', body: { responseType: 'any', responseTypeWithNamespace: 'any', acceptHeader: 'application/xml' } }, ])('rendered service compiles cleanly under real ts.Program ($name)', ({ body }) => { - // ts.transpileModule is a single-file transform; it can't resolve @abp/ng.core - // imports. Real ts.createProgram with a host that supplies a minimal .d.ts stub - // for @abp/ng.core exercises the actual semantic checker — closer to ng build - // without the cost of a full Angular workspace. const ts = require('typescript'); const ctx = buildContext(body as Partial); - // Mirror what the real schematic emits: every action gets a trailing - // `config?: Partial` parameter — without it the rendered call - // site references a `config` identifier that's never declared. - // Use a permissive type so strict-mode spread doesn't trip on Partial<{...}> - // semantics; the real schematic uses `Partial` but the spread - // pattern is identical and we want to assert template *shape*, not the - // exact inference Angular's strict mode does for the upstream type. ctx.methods[0].signature.parameters = [{ name: 'config', type: 'Record' } as any]; const output = render(ctx); @@ -235,7 +224,6 @@ describe('proxy service template — rendered output', () => { const angularCoreStub = ` declare module '@angular/core' { export function Injectable(opts?: any): ClassDecorator; - // Mirror Angular's overload that accepts a class token and returns its instance. export function inject(token: { new (...args: any[]): T }): T; export function inject(token: any): T; } @@ -246,8 +234,6 @@ describe('proxy service template — rendered output', () => { } `; - // Bundle the @abp/ng.core / @angular/core / rxjs ambient declarations into - // a single .d.ts root file so TS sees them as ambient module declarations. const ambient = abpStub + angularCoreStub + rxjsStub + domStub; const sources: Record = { '/proxy/sample.service.ts': output, @@ -285,7 +271,6 @@ describe('proxy service template — rendered output', () => { .getPreEmitDiagnostics(program) .filter((d: any) => d.category === ts.DiagnosticCategory.Error && - // Ignore TS6053 "File 'lib.x' not found" which is unrelated to our generated code. d.code !== 6053, ); @@ -306,4 +291,205 @@ describe('proxy service template — rendered output', () => { } expect(errors).toHaveLength(0); }); + + test.each([ + { + name: 'DTO upload — single FormData arg', + signatureParams: [ + { name: 'input', type: 'FormData' }, + { name: 'config', type: 'Record' }, + ], + bodyOverrides: { method: 'POST', url: "'/api/test/upload-single'", body: 'input' }, + shouldContain: ['input: FormData', 'body: input'], + }, + { + name: 'direct upload — FormData arg with custom name', + signatureParams: [ + { name: 'file', type: 'FormData' }, + { name: 'config', type: 'Record' }, + ], + bodyOverrides: { method: 'POST', url: "'/api/test/upload-direct'", body: 'file' }, + shouldContain: ['file: FormData', 'body: file'], + }, + { + name: 'path + upload mixed — id stays in URL, FormData becomes body', + signatureParams: [ + { name: 'id', type: 'number' }, + { name: 'input', type: 'FormData' }, + { name: 'config', type: 'Record' }, + ], + bodyOverrides: { method: 'POST', url: '`/api/test/upload-with-path/${id}`', body: 'input' }, + shouldContain: ['id: number', 'input: FormData', 'body: input'], + }, + { + name: 'query + upload mixed — tag in params, FormData in body', + signatureParams: [ + { name: 'tag', type: 'string' }, + { name: 'input', type: 'FormData' }, + { name: 'config', type: 'Record' }, + ], + bodyOverrides: { + method: 'POST', + url: "'/api/test/upload-with-query'", + params: ['tag'], + body: 'input', + }, + shouldContain: ['tag: string', 'input: FormData', 'params: { tag }', 'body: input'], + }, + ])('upload action signature collapses to FormData ($name)', ({ signatureParams, bodyOverrides, shouldContain }) => { + const ts = require('typescript'); + const ctx = buildContext({ + responseType: 'string', + responseTypeWithNamespace: 'string', + ...bodyOverrides, + } as Partial); + ctx.methods[0].signature.parameters = signatureParams as any; + const output = render(ctx); + + for (const fragment of shouldContain) { + expect(output).toContain(fragment); + } + expect(output).not.toContain('JSON.stringify'); + + const abpStub = ` + declare module '@abp/ng.core' { + export namespace Rest { + export interface Config { + apiName?: string; + observe?: any; + skipHandleError?: boolean; + responseType?: string; + [key: string]: any; + } + export type Observe = any; + } + export class RestService { + request(req: any, config?: any): import('rxjs').Observable; + } + } + `; + const angularCoreStub = ` + declare module '@angular/core' { + export function Injectable(opts?: any): ClassDecorator; + export function inject(token: { new (...args: any[]): T }): T; + export function inject(token: any): T; + } + `; + const rxjsStub = ` + declare module 'rxjs' { + export class Observable { subscribe(...args: any[]): unknown; } + } + `; + const domStub = ` + declare class Blob { constructor(parts?: any[], options?: any); } + declare class FormData { + constructor(); + append(name: string, value: string | Blob, fileName?: string): void; + get(name: string): any; + } + `; + const ambient = abpStub + angularCoreStub + rxjsStub + domStub; + const sources: Record = { + '/proxy/sample.service.ts': output, + '/proxy/ambient.d.ts': ambient, + }; + const compilerOptions: any = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.ES2020, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + experimentalDecorators: true, + emitDecoratorMetadata: true, + strict: true, + noEmit: true, + skipLibCheck: true, + }; + const baseHost = ts.createCompilerHost(compilerOptions, true); + const host: any = { + ...baseHost, + getSourceFile: (fileName: string, languageVersion: any, onError: any) => + sources[fileName] + ? ts.createSourceFile(fileName, sources[fileName], languageVersion, true) + : baseHost.getSourceFile(fileName, languageVersion, onError), + fileExists: (fileName: string) => + sources[fileName] != null || baseHost.fileExists(fileName), + readFile: (fileName: string) => + sources[fileName] ?? baseHost.readFile(fileName), + }; + const program = ts.createProgram(Object.keys(sources), compilerOptions, host); + const errors = ts + .getPreEmitDiagnostics(program) + .filter((d: any) => d.category === ts.DiagnosticCategory.Error && d.code !== 6053); + if (errors.length) { + const messages = errors + .map((d: any) => { + const where = d.file + ? (() => { + const p = d.file.getLineAndCharacterOfPosition(d.start ?? 0); + const lineText = d.file.text.split('\n')[p.line]; + return `${d.file.fileName}:${p.line + 1}:${p.character + 1}\n>>> ${lineText}\n>>> ${' '.repeat(p.character)}^`; + })() + : '(no file)'; + return `[${where}] TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`; + }) + .join('\n---\n'); + throw new Error(`Upload action proxy did not compile:\n${output}\n=== diagnostics ===\n${messages}`); + } + expect(errors).toHaveLength(0); + }); + + test('rendered upload service forwards FormData to restService.request at runtime', () => { + const ts = require('typescript'); + const ctx = buildContext({ + method: 'POST', + url: "'/api/upload-runtime'", + responseType: 'string', + responseTypeWithNamespace: 'string', + body: 'input', + }); + ctx.methods[0].signature.parameters = [ + { name: 'input', type: 'FormData' }, + { name: 'config', type: 'Record' }, + ] as any; + const output = render(ctx); + + const stripped = output + .replace(/^import .*?;\s*$/gm, '') + .replace(/@Injectable\(\{[\s\S]*?\}\)\s*\n/g, '') + .replace(/private restService = inject\(RestService\);/, 'restService;') + .replace(/this\.restService\.request<[^>]+,\s*[^>]+>/g, 'this.restService.request'); + + const transpiled = ts.transpileModule(stripped, { + compilerOptions: { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + experimentalDecorators: true, + }, + }).outputText; + + const restMockCalls: Array<{ body: any; method: string; url: string; headers?: any }> = []; + const restMock = { + request: (req: any /* , _config: any */) => { + restMockCalls.push(req); + return { subscribe: () => undefined }; + }, + }; + + const vm = require('vm'); + const sandbox: Record = { exports: {} }; + vm.createContext(sandbox); + vm.runInContext(transpiled + '\nexports.SampleService = SampleService;', sandbox); + const ServiceCls = sandbox.exports.SampleService; + const instance = new ServiceCls(); + instance.restService = restMock; + + const GlobalFormData = (globalThis as any).FormData; + const formData = typeof GlobalFormData === 'function' + ? new GlobalFormData() + : { __isFormData: true, append: () => undefined }; + instance.getSampleAsync(formData, { apiName: 'Default' }); + + expect(restMockCalls).toHaveLength(1); + expect(restMockCalls[0].body).toBe(formData); + expect(restMockCalls[0].method).toBe('POST'); + }); }); diff --git a/npm/ng-packs/packages/schematics/src/utils/service.ts b/npm/ng-packs/packages/schematics/src/utils/service.ts index d5d65326d9..1c808c9d3b 100644 --- a/npm/ng-packs/packages/schematics/src/utils/service.ts +++ b/npm/ng-packs/packages/schematics/src/utils/service.ts @@ -115,7 +115,27 @@ export function createActionToBodyMapper() { acceptHeader, }); - parameters.forEach(body.registerActionParameter); + const uploadMethodArgNames = new Set( + parameters + .filter(p => p.bindingSourceId === eBindingSourceId.FormFile) + .map(p => p.nameOnMethod), + ); + if (uploadMethodArgNames.size > 0) { + body.body = camelizeHyphen([...uploadMethodArgNames][0]); + parameters + .filter(p => { + if (uploadMethodArgNames.has(p.nameOnMethod)) { + return false; + } + return ( + p.bindingSourceId !== eBindingSourceId.Form && + p.bindingSourceId !== eBindingSourceId.FormFile + ); + }) + .forEach(body.registerActionParameter); + } else { + parameters.forEach(body.registerActionParameter); + } return body; }; @@ -218,7 +238,16 @@ export function createActionToSignatureMapper() { ...(versionParameter ? [versionParameter] : []), ]; + const uploadMethodArgNames = new Set( + (action.parameters ?? []) + .filter(p => p.bindingSourceId === eBindingSourceId.FormFile) + .map(p => p.nameOnMethod), + ); + signature.parameters = parameters.map(p => { + if (uploadMethodArgNames.has(p.name)) { + return new Property({ name: p.name, type: 'FormData' }); + } const isFormData = isRemoteStreamContent(p.type); const isFormArray = isRemoteStreamContentArray(p.type); if (isFormData || isFormArray) { From 1a65d79c6aa3882b44082dbbbed3d0468006b294 Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 17 Jun 2026 12:36:35 +0800 Subject: [PATCH 15/16] Cover Angular signature/method mappers and pin jQuery multi-FormFile limit --- ...ProxyScriptGenerator_ContentTypes_Tests.cs | 15 ++ .../src/tests/action-to-body-mapper.spec.ts | 128 +++++++++++++++++- 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs index f6704ea0bd..a7c63f8270 100644 --- a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs @@ -167,6 +167,21 @@ public class JQueryProxyScriptGenerator_ContentTypes_Tests script.ShouldNotContain("JSON.stringify"); } + [Fact] + public void Multiple_Direct_FormFile_Params_Should_Forward_Only_First_Var_Known_Limitation() + { + var script = _generator.CreateScript(BuildUploadModel( + uploadParameters: new[] + { + ("file1", "file1", ParameterBindingSources.FormFile), + ("file2", "file2", ParameterBindingSources.FormFile), + })); + + script.ShouldContain("data: file1"); + script.ShouldNotContain("data: file2"); + script.ShouldNotContain("$.merge(file1, file2)"); + } + private static ApplicationApiDescriptionModel BuildAppModel(string returnType, IList? contentTypes, bool isRemoteStream = false) { var model = ApplicationApiDescriptionModel.Create(); diff --git a/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts b/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts index 7156759689..b080144e8b 100644 --- a/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts +++ b/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts @@ -3,7 +3,11 @@ import { join } from 'path'; import { describe, expect, test } from 'vitest'; import { eBindingSourceId } from '../enums'; import { Action } from '../models'; -import { createActionToBodyMapper } from '../utils/service'; +import { + createActionToBodyMapper, + createActionToMethodMapper, + createActionToSignatureMapper, +} from '../utils/service'; const TEMPLATE_PATH = join( __dirname, @@ -704,3 +708,125 @@ describe('createActionToBodyMapper — multipart upload params regression', () = expect(body.params.join(',')).not.toContain('name'); }); }); + +describe('createActionToSignatureMapper — multipart upload signature collapse', () => { + const mapSignature = createActionToSignatureMapper(); + + test('FormFile DTO method arg collapses to FormData type', () => { + const sig = mapSignature(buildAction({ + httpMethod: 'POST', + url: 'api/app/proxy-demo-test/upload-single', + parametersOnMethod: [ + { name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, + { nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial)); + const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(types).toContain('input:FormData'); + expect(types[types.length - 1]).toBe('config:Partial'); + }); + + test('Path arg keeps its primitive type while upload arg becomes FormData', () => { + const sig = mapSignature(buildAction({ + httpMethod: 'POST', + url: 'api/proxy-demo/media/upload-with-path/{id}', + parametersOnMethod: [ + { name: 'id', type: 'System.Int32', typeAsString: 'System.Int32', typeSimple: 'number', isOptional: false, defaultValue: null } as any, + { name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'id', name: 'id', type: 'System.Int32', typeSimple: 'int', bindingSourceId: eBindingSourceId.Path } as any, + { nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, + { nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial)); + const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(types).toEqual([ + 'id:number', + 'input:FormData', + 'config:Partial', + ]); + }); + + test('Multiple direct IRemoteStreamContent method args each become FormData independently', () => { + const sig = mapSignature(buildAction({ + httpMethod: 'POST', + url: 'api/proxy-demo/media/upload-two-direct', + parametersOnMethod: [ + { name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeAsString: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', isOptional: false, defaultValue: null } as any, + { name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeAsString: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'file1', name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + { nameOnMethod: 'file2', name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial)); + const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(types).toEqual([ + 'file1:FormData', + 'file2:FormData', + 'config:Partial', + ]); + }); + + test('Non-upload action signature stays untouched (regression guard)', () => { + const sig = mapSignature(buildAction({ + httpMethod: 'GET', + url: 'api/app/proxy-demo-test/get-item-by-id', + parametersOnMethod: [ + { name: 'id', type: 'System.Int32', typeAsString: 'System.Int32', typeSimple: 'number', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'id', name: 'id', type: 'System.Int32', typeSimple: 'int', bindingSourceId: eBindingSourceId.Path } as any, + ], + } as Partial)); + const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(types).toEqual(['id:number', 'config:Partial']); + }); +}); + +describe('createActionToMethodMapper — signature + body wired together for upload actions', () => { + const mapMethod = createActionToMethodMapper(); + + test('Upload action produces both FormData signature and FormData body in one pass', () => { + const method = mapMethod(buildAction({ + httpMethod: 'POST', + url: 'api/app/proxy-demo-test/upload-single', + parametersOnMethod: [ + { name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, + { nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial)); + const sigTypes = method.signature.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(sigTypes).toContain('input:FormData'); + expect(method.body.body).toBe('input'); + expect(method.body.params.join(',')).not.toContain('name'); + }); + + test('Query + ModelBinding + FormFile mix preserves Query as URL param and drops upload-arg fields', () => { + const method = mapMethod(buildAction({ + httpMethod: 'POST', + url: 'api/proxy-demo/media/upload-with-query', + parametersOnMethod: [ + { name: 'tag', type: 'System.String', typeAsString: 'System.String', typeSimple: 'string', isOptional: false, defaultValue: null } as any, + { name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'tag', name: 'tag', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Query } as any, + { nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, + { nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial)); + const sigTypes = method.signature.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(sigTypes).toEqual(['tag:string', 'input:FormData', 'config:Partial']); + expect(method.body.body).toBe('input'); + expect(method.body.params).toContain('tag'); + expect(method.body.params.join(',')).not.toContain('name'); + }); +}); From 68b6b0acd810226266f342d0f5824a769c5dcdbd Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 17 Jun 2026 14:55:06 +0800 Subject: [PATCH 16/16] Pin Angular body mapper limit for multiple direct FormFile method args --- .../src/tests/action-to-body-mapper.spec.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts b/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts index b080144e8b..9e35e9ef8c 100644 --- a/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts +++ b/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts @@ -707,6 +707,20 @@ describe('createActionToBodyMapper — multipart upload params regression', () = expect(body.url).toContain('${id}'); expect(body.params.join(',')).not.toContain('name'); }); + + test('Multiple direct FormFile method args: body forwards only the first var (known limitation, mirrors jQuery)', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + url: 'api/proxy-demo/media/upload-two-direct', + parameters: [ + { nameOnMethod: 'file1', name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + { nameOnMethod: 'file2', name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial), + ); + expect(body.body).toBe('file1'); + }); }); describe('createActionToSignatureMapper — multipart upload signature collapse', () => {