From b39aa93d37812132ed0b9f6039b286c2f08144e8 Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 17 Jun 2026 12:23:43 +0800 Subject: [PATCH] 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) {