Browse Source

Forward IRemoteStreamContent DTO uploads as multipart in jQuery and Angular client proxies

pull/25639/head
maliming 4 months ago
parent
commit
b39aa93d37
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 17
      framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs
  2. 804
      framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyRequestPayloadBuilder_FormData_Tests.cs
  3. 113
      framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs
  4. 1
      npm/ng-packs/packages/schematics/src/enums/binding-source-id.ts
  5. 128
      npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts
  6. 216
      npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts
  7. 31
      npm/ng-packs/packages/schematics/src/utils/service.ts

17
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); 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); var dataType = GetJQueryDataTypeAndAcceptOverride(action);
script.AppendLine(ajaxParamsIsFromForm script.AppendLine(ajaxParamsIsFromForm
? " }, $.extend(true, {}, " + dataType + "{ contentType: 'application/x-www-form-urlencoded; charset=UTF-8' }, ajaxParams)));" ? " }, $.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); 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); var body = ProxyScriptingHelper.GenerateBody(action);
if (!body.IsNullOrEmpty()) if (!body.IsNullOrEmpty())
{ {

804
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<IServiceScopeFactory>();
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<string, object?>
{
["file"] = new RemoteStreamContent(stream, "demo.txt", "text/plain"),
};
var content = await InvokeAsync(action, args);
var multipart = content.ShouldBeOfType<MultipartFormDataContent>();
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<string, object?>
{
["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<MultipartFormDataContent>();
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<string, object?>
{
["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<MultipartFormDataContent>().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<string, object?>
{
["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<MultipartFormDataContent>().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<string, object?>
{
["input"] = new TestUploadDto { Name = "Alice", Tag = "T1" },
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().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<string, object?>
{
["input"] = new TestUploadDto { Name = "Alice" },
};
var content = await InvokeAsync(action, args);
var stringContent = content.ShouldBeOfType<StringContent>();
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<string, object?> { ["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<string, object?>
{
["input"] = new TestUploadDto { Name = "Alice", File = null },
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().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<string, object?>
{
["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<MultipartFormDataContent>().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<string, object?>
{
["file"] = new RemoteStreamContent(MakeStream("hello-utf8"), "中文-文件名.txt", "text/plain"),
};
var content = await InvokeAsync(action, args);
var part = content.ShouldBeOfType<MultipartFormDataContent>().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<string, object?>
{
["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>();
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<string, object?>
{
["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<MultipartFormDataContent>().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<string, object?>
{
["input"] = new TestUploadDto { Name = "A", File = new RemoteStreamContent(MakeStream("aa"), "a.txt", "text/plain") },
};
var argsB = new Dictionary<string, object?>
{
["input"] = new TestUploadFilesDto
{
Label = "B",
Files = new[] { new RemoteStreamContent(MakeStream("bb"), "b.txt", "text/csv") },
},
};
var contentA = (await InvokeAsync(actionA, argsA)).ShouldBeOfType<MultipartFormDataContent>().ToList();
var contentB = (await InvokeAsync(actionB, argsB)).ShouldBeOfType<MultipartFormDataContent>().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<string, object?>
{
["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<MultipartFormDataContent>().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<string, object?>
{
["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<MultipartFormDataContent>().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<string, object?>
{
["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<MultipartFormDataContent>().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<string, object?>
{
["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<MultipartFormDataContent>().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<string, object?> { ["input"] = polymorphic };
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().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<string, object?>
{
["input"] = new TestGenericUploadDto<string>
{
Payload = "closed-string",
File = new RemoteStreamContent(MakeStream("g.txt-body"), "g.txt", "text/plain"),
},
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().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<string, object?>
{
["input"] = new TestGenericUploadDto<int>
{
Payload = 42,
File = new RemoteStreamContent(MakeStream("int-payload"), "i.txt", "text/plain"),
},
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().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<string, object?>
{
["file"] = new RemoteStreamContent(sourceStream, "r.txt", "text/plain"),
};
var content = await InvokeAsync(action, args);
var part = content.ShouldBeOfType<MultipartFormDataContent>().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<ParameterApiDescriptionModel>());
var args = new Dictionary<string, object?>();
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<string, object?>
{
["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<MultipartFormDataContent>().ToList();
parts.Count.ShouldBe(2);
await AssertStringPart(parts[0], "Name", "Bob");
await AssertStreamPart(parts[1], "File", "path-mixed", "text/plain");
}
private Task<HttpContent?> InvokeAsync(ActionApiDescriptionModel action, IReadOnlyDictionary<string, object?> 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<string>(),
ParametersOnMethod = new List<MethodParameterApiDescriptionModel>(),
Parameters = parameters.ToList(),
ReturnValue = new ReturnValueApiDescriptionModel
{
Type = "System.String",
TypeSimple = "string",
},
AuthorizeDatas = new List<AuthorizeDataApiDescriptionModel>(),
};
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<IRemoteStreamContent>? 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<IRemoteStreamContent>? 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<T>
{
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<T>(string jsonString, bool camelCase = true)
=> System.Text.Json.JsonSerializer.Deserialize<T>(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;
}
}

113
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] [Fact]
public void IsRemoteStream_Should_Skip_DataType_Override_To_Avoid_JSON_Metadata_Regression() public void IsRemoteStream_Should_Skip_DataType_Override_To_Avoid_JSON_Metadata_Regression()
{ {
// For IRemoteStreamContent returns the API definition still advertises // IRemoteStreamContent: ABP advertises application/json in formatter list, but
// application/json (server-side default formatter list). The generator // forcing dataType:'json' here would make the server JSON-serialise the stream.
// 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( var model = BuildAppModel(
returnType: "Volo.Abp.Content.IRemoteStreamContent", returnType: "Volo.Abp.Content.IRemoteStreamContent",
contentTypes: new[] { "text/plain", "application/json", "text/json" }, contentTypes: new[] { "text/plain", "application/json", "text/json" },
@ -113,6 +109,64 @@ public class JQueryProxyScriptGenerator_ContentTypes_Tests
script.ShouldNotContain("Accept: 'application/json'"); 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<string>? contentTypes, bool isRemoteStream = false) private static ApplicationApiDescriptionModel BuildAppModel(string returnType, IList<string>? contentTypes, bool isRemoteStream = false)
{ {
var model = ApplicationApiDescriptionModel.Create(); var model = ApplicationApiDescriptionModel.Create();
@ -147,4 +201,51 @@ public class JQueryProxyScriptGenerator_ContentTypes_Tests
return model; 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<ParameterApiDescriptionModel>();
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<string>(),
ParametersOnMethod = new List<MethodParameterApiDescriptionModel>(),
Parameters = parameters,
ReturnValue = new ReturnValueApiDescriptionModel
{
Type = "System.Void",
TypeSimple = "void",
},
AuthorizeDatas = new List<AuthorizeDataApiDescriptionModel>(),
};
controller.AddAction("UploadAsync", action);
return model;
}
} }

1
npm/ng-packs/packages/schematics/src/enums/binding-source-id.ts

@ -3,5 +3,6 @@ export enum eBindingSourceId {
Model = 'ModelBinding', Model = 'ModelBinding',
Path = 'Path', Path = 'Path',
Query = 'Query', Query = 'Query',
Form = 'Form',
FormFile = 'FormFile', FormFile = 'FormFile',
} }

128
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'/); 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<Action>),
);
expect(body.body).toBe('input');
expect(body.params).toEqual([]);
});
test('DTO with IEnumerable<IRemoteStreamContent> 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<Volo.Abp.Content.IRemoteStreamContent>', typeSimple: '[Volo.Abp.Content.IRemoteStreamContent]', bindingSourceId: eBindingSourceId.FormFile } as any,
],
} as Partial<Action>),
);
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<Action>),
);
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<Action>),
);
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<Action>),
);
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<Action>),
);
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<Action>),
);
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<Action>),
);
expect(body.body).toBe('input');
expect(body.url).toContain('${id}');
expect(body.params.join(',')).not.toContain('name');
});
});

216
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: 'any[] degradation', body: { responseType: 'any[]', responseTypeWithNamespace: '[Volo.Abp.Content.IRemoteStreamContent]' } },
{ name: 'xml Accept only', body: { responseType: 'any', responseTypeWithNamespace: 'any', acceptHeader: 'application/xml' } }, { name: 'xml Accept only', body: { responseType: 'any', responseTypeWithNamespace: 'any', acceptHeader: 'application/xml' } },
])('rendered service compiles cleanly under real ts.Program ($name)', ({ body }) => { ])('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 ts = require('typescript');
const ctx = buildContext(body as Partial<MockBody>); const ctx = buildContext(body as Partial<MockBody>);
// Mirror what the real schematic emits: every action gets a trailing
// `config?: Partial<Rest.Config>` 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<Rest.Config>` 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<string, any>' } as any]; ctx.methods[0].signature.parameters = [{ name: 'config', type: 'Record<string, any>' } as any];
const output = render(ctx); const output = render(ctx);
@ -235,7 +224,6 @@ describe('proxy service template — rendered output', () => {
const angularCoreStub = ` const angularCoreStub = `
declare module '@angular/core' { declare module '@angular/core' {
export function Injectable(opts?: any): ClassDecorator; export function Injectable(opts?: any): ClassDecorator;
// Mirror Angular's overload that accepts a class token and returns its instance.
export function inject<T>(token: { new (...args: any[]): T }): T; export function inject<T>(token: { new (...args: any[]): T }): T;
export function inject<T>(token: any): T; export function inject<T>(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 ambient = abpStub + angularCoreStub + rxjsStub + domStub;
const sources: Record<string, string> = { const sources: Record<string, string> = {
'/proxy/sample.service.ts': output, '/proxy/sample.service.ts': output,
@ -285,7 +271,6 @@ describe('proxy service template — rendered output', () => {
.getPreEmitDiagnostics(program) .getPreEmitDiagnostics(program)
.filter((d: any) => .filter((d: any) =>
d.category === ts.DiagnosticCategory.Error && d.category === ts.DiagnosticCategory.Error &&
// Ignore TS6053 "File 'lib.x' not found" which is unrelated to our generated code.
d.code !== 6053, d.code !== 6053,
); );
@ -306,4 +291,205 @@ describe('proxy service template — rendered output', () => {
} }
expect(errors).toHaveLength(0); expect(errors).toHaveLength(0);
}); });
test.each([
{
name: 'DTO upload — single FormData arg',
signatureParams: [
{ name: 'input', type: 'FormData' },
{ name: 'config', type: 'Record<string, any>' },
],
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<string, any>' },
],
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<string, any>' },
],
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<string, any>' },
],
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<MockBody>);
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<TBody, TResponse>(req: any, config?: any): import('rxjs').Observable<TResponse>;
}
}
`;
const angularCoreStub = `
declare module '@angular/core' {
export function Injectable(opts?: any): ClassDecorator;
export function inject<T>(token: { new (...args: any[]): T }): T;
export function inject<T>(token: any): T;
}
`;
const rxjsStub = `
declare module 'rxjs' {
export class Observable<T> { 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<string, string> = {
'/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<string, any>' },
] 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<string, any> = { 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');
});
}); });

31
npm/ng-packs/packages/schematics/src/utils/service.ts

@ -115,7 +115,27 @@ export function createActionToBodyMapper() {
acceptHeader, 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; return body;
}; };
@ -218,7 +238,16 @@ export function createActionToSignatureMapper() {
...(versionParameter ? [versionParameter] : []), ...(versionParameter ? [versionParameter] : []),
]; ];
const uploadMethodArgNames = new Set(
(action.parameters ?? [])
.filter(p => p.bindingSourceId === eBindingSourceId.FormFile)
.map(p => p.nameOnMethod),
);
signature.parameters = parameters.map(p => { signature.parameters = parameters.map(p => {
if (uploadMethodArgNames.has(p.name)) {
return new Property({ name: p.name, type: 'FormData' });
}
const isFormData = isRemoteStreamContent(p.type); const isFormData = isRemoteStreamContent(p.type);
const isFormArray = isRemoteStreamContentArray(p.type); const isFormArray = isRemoteStreamContentArray(p.type);
if (isFormData || isFormArray) { if (isFormData || isFormArray) {

Loading…
Cancel
Save