mirror of https://github.com/abpframework/abp.git
Browse Source
Expose response ContentTypes in api-definition and use them in proxiespull/25658/head
committed by
GitHub
24 changed files with 4102 additions and 34 deletions
@ -0,0 +1,275 @@ |
|||
#nullable enable |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Net.Http; |
|||
using Shouldly; |
|||
using Volo.Abp.Content; |
|||
using Volo.Abp.Http.Modeling; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Http.Client.ClientProxying; |
|||
|
|||
public class ClientProxyBase_GetAcceptForActionReturn_Tests |
|||
{ |
|||
[Fact] |
|||
public void IRemoteStreamContent_Should_Pick_OctetStream_Even_When_ContentTypes_Include_Json() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: typeof(IRemoteStreamContent).FullName!, |
|||
contentTypes: new[] { "application/json", "text/plain", "text/json" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void RemoteStreamContent_Concrete_Type_Should_Pick_OctetStream() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: typeof(RemoteStreamContent).FullName!, |
|||
contentTypes: null); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Json_In_ContentTypes_Should_Pick_Json() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "text/plain", "application/json", "text/json" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Only_Text_ContentTypes_Should_Pick_TextPlain() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "text/plain", "text/csv" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Empty_Or_Null_ContentTypes_Should_Return_Null() |
|||
{ |
|||
InvokeGetAcceptForActionReturn(BuildAction("System.Int32", null)).ShouldBeNull(); |
|||
InvokeGetAcceptForActionReturn(BuildAction("System.Int32", new string[0])).ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Mixed_Text_And_Octet_Stream_Should_Echo_First_Content_Type() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "text/plain", "application/octet-stream" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void JsonV2_Variant_Should_Still_Pick_Json() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "application/json; charset=utf-8" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Single_TextHtml_Should_Echo_Back_TextHtml() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "text/html" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("text/html"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void OctetStream_Only_With_ObjectReturn_Should_Echo_OctetStream() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "My.Project.UserDto", |
|||
contentTypes: new[] { "application/octet-stream" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void ApplicationXml_Only_Should_Echo_Back_Xml_Instead_Of_Legacy_Pair() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "My.Project.SoapEnvelope", |
|||
contentTypes: new[] { "application/xml" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/xml"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Case_Insensitive_Json_Match() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "APPLICATION/JSON" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Json_With_Charset_Parameter_Should_Still_Pick_Json() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "application/json; charset=utf-8" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/json"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Text_With_Charset_Parameter_Should_Still_Pick_TextPlain() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "text/plain ; charset=utf-8 " }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("text/plain"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Text_Json_Should_Echo_Back_Text_Json() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "text/json" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("text/json"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Application_Problem_Json_Should_Echo_Back_The_Plus_Json_Variant() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "application/problem+json" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/problem+json"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Vendor_Plus_Json_Should_Echo_Back_The_Plus_Json_Variant() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "application/vnd.api+json" }); |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/vnd.api+json"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void IsRemoteStream_Flag_True_Should_Pick_OctetStream_Regardless_Of_TypeName() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: "My.Project.CustomStream", |
|||
contentTypes: new[] { "application/json" }); |
|||
action.ReturnValue.IsRemoteStream = true; |
|||
|
|||
InvokeGetAcceptForActionReturn(action).ShouldBe("application/octet-stream"); |
|||
} |
|||
|
|||
private static string? InvokeGetAcceptForActionReturn(ActionApiDescriptionModel action) |
|||
{ |
|||
var proxy = new TestableClientProxy(); |
|||
return proxy.PublicGetAcceptForActionReturn(action); |
|||
} |
|||
|
|||
private static ActionApiDescriptionModel BuildAction(string returnType, IList<string>? contentTypes) |
|||
{ |
|||
return new ActionApiDescriptionModel |
|||
{ |
|||
UniqueName = "Sample", |
|||
Name = "Sample", |
|||
HttpMethod = "GET", |
|||
Url = "api/test", |
|||
SupportedVersions = new List<string>(), |
|||
ParametersOnMethod = new List<MethodParameterApiDescriptionModel>(), |
|||
Parameters = new List<ParameterApiDescriptionModel>(), |
|||
ReturnValue = new ReturnValueApiDescriptionModel |
|||
{ |
|||
Type = returnType, |
|||
TypeSimple = returnType, |
|||
ContentTypes = contentTypes |
|||
}, |
|||
AuthorizeDatas = new List<AuthorizeDataApiDescriptionModel>() |
|||
}; |
|||
} |
|||
|
|||
[Fact] |
|||
public void AddHeaders_With_ApiVersion_Should_Combine_OctetStream_Accept_With_Version_Suffix() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: typeof(IRemoteStreamContent).FullName!, |
|||
contentTypes: new[] { "application/json", "text/plain" }); |
|||
|
|||
var headers = InvokeAddHeadersAndCollectAccept(action, version: "2.0"); |
|||
|
|||
headers.ShouldContain("application/octet-stream; v=2.0"); |
|||
headers.ShouldNotContain(h => h == "text/plain; v=2.0"); |
|||
headers.ShouldNotContain(h => h == "application/json; v=2.0"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void AddHeaders_Without_ApiVersion_Should_Emit_OctetStream_For_Stream_Returns() |
|||
{ |
|||
var action = BuildAction( |
|||
returnType: typeof(IRemoteStreamContent).FullName!, |
|||
contentTypes: new[] { "application/json" }); |
|||
|
|||
var headers = InvokeAddHeadersAndCollectAccept(action, version: null); |
|||
|
|||
headers.ShouldContain("application/octet-stream"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void AddHeaders_Without_ContentType_Metadata_Should_Fall_Back_To_Text_And_Json_Pair() |
|||
{ |
|||
var action = BuildAction(returnType: "System.Int32", contentTypes: null); |
|||
|
|||
var headers = InvokeAddHeadersAndCollectAccept(action, version: "1.0"); |
|||
|
|||
headers.ShouldContain("text/plain; v=1.0"); |
|||
headers.ShouldContain("application/json; v=1.0"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void AddHeaders_Without_ApiVersion_And_Without_ContentType_Metadata_Should_Emit_Unversioned_Text_Json_Pair() |
|||
{ |
|||
var action = BuildAction(returnType: "System.Int32", contentTypes: null); |
|||
|
|||
var headers = InvokeAddHeadersAndCollectAccept(action, version: null); |
|||
|
|||
headers.ShouldContain("text/plain"); |
|||
headers.ShouldContain("application/json"); |
|||
headers.ShouldNotContain(h => h.Contains("; v=")); |
|||
} |
|||
|
|||
private static IList<string> InvokeAddHeadersAndCollectAccept(ActionApiDescriptionModel action, string? version) |
|||
{ |
|||
var proxy = new TestableClientProxy(); |
|||
var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/x"); |
|||
var apiVersion = new ApiVersionInfo("HeaderModelBinding", version ?? string.Empty); |
|||
proxy.PublicAddAcceptHeaders(action, message, apiVersion); |
|||
return message.Headers.Accept.Select(a => a.ToString()).ToList(); |
|||
} |
|||
|
|||
private sealed class TestableClientProxy : ClientProxyBase<object> |
|||
{ |
|||
public string? PublicGetAcceptForActionReturn(ActionApiDescriptionModel action) |
|||
=> GetAcceptForActionReturn(action); |
|||
|
|||
public void PublicAddAcceptHeaders(ActionApiDescriptionModel action, HttpRequestMessage requestMessage, ApiVersionInfo apiVersion) |
|||
=> AddAcceptHeaders(action, requestMessage, apiVersion); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -0,0 +1,84 @@ |
|||
using System.IO; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Shouldly; |
|||
using Volo.Abp.Http.Client; |
|||
using Volo.Abp.TestApp.Application; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Http.DynamicProxying; |
|||
|
|||
public class PeopleAppServiceClientProxy_ReturnContentTypes_Tests : AbpHttpClientTestBase |
|||
{ |
|||
private readonly IPeopleAppService _peopleAppService; |
|||
|
|||
public PeopleAppServiceClientProxy_ReturnContentTypes_Tests() |
|||
{ |
|||
_peopleAppService = ServiceProvider.GetRequiredService<IPeopleAppService>(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task EchoStatusAsync_Should_Return_Plain_String_Without_Quotes() |
|||
{ |
|||
var status = await _peopleAppService.EchoStatusAsync(); |
|||
status.ShouldBe("Open"); |
|||
status.StartsWith("\"").ShouldBeFalse(); |
|||
status.EndsWith("\"").ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task EchoStatusWithProducesJsonAsync_Should_Return_Plain_String_Without_Quotes() |
|||
{ |
|||
var status = await _peopleAppService.EchoStatusWithProducesJsonAsync(); |
|||
status.ShouldBe("Open"); |
|||
status.StartsWith("\"").ShouldBeFalse(); |
|||
status.EndsWith("\"").ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetBinaryImageAsync_Should_Return_Real_Binary_Not_Json_Metadata() |
|||
{ |
|||
using var content = await _peopleAppService.GetBinaryImageAsync(); |
|||
using var ms = new MemoryStream(); |
|||
await content.GetStream().CopyToAsync(ms); |
|||
var bytes = ms.ToArray(); |
|||
|
|||
content.FileName.ShouldBe("tiny.png"); |
|||
content.ContentType.ShouldStartWith("image/png"); |
|||
bytes.Length.ShouldBeGreaterThan(8); |
|||
|
|||
bytes[0].ShouldBe((byte)0x89); |
|||
bytes[1].ShouldBe((byte)0x50); |
|||
bytes[2].ShouldBe((byte)0x4E); |
|||
bytes[3].ShouldBe((byte)0x47); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task ThrowFromStringAsync_Should_Surface_Server_Exception_To_Client() |
|||
{ |
|||
await Should.ThrowAsync<AbpRemoteCallException>( |
|||
() => _peopleAppService.ThrowFromStringAsync() |
|||
); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task DownloadAsync_Should_Still_Work() |
|||
{ |
|||
using var content = await _peopleAppService.DownloadAsync(); |
|||
using var reader = new StreamReader(content.GetStream()); |
|||
var text = await reader.ReadToEndAsync(); |
|||
text.ShouldBe("DownloadAsync"); |
|||
content.FileName.ShouldBe("download.rtf"); |
|||
content.ContentType.ShouldStartWith("application/rtf"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task UploadAsync_String_Return_Should_Stay_Unquoted() |
|||
{ |
|||
using var ms = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("hello")); |
|||
var result = await _peopleAppService.UploadAsync( |
|||
new Content.RemoteStreamContent(ms, "upload.txt", "text/plain")); |
|||
result.ShouldBe("hello:text/plain:upload.txt"); |
|||
result.StartsWith("\"").ShouldBeFalse(); |
|||
} |
|||
} |
|||
@ -0,0 +1,103 @@ |
|||
using System.IO; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Extensions.DependencyInjection; |
|||
using Shouldly; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Http.DynamicProxying; |
|||
|
|||
public class RegularTestControllerClientProxy_ReturnContentTypes_Tests : AbpHttpClientTestBase |
|||
{ |
|||
private readonly IRegularTestController _controller; |
|||
|
|||
public RegularTestControllerClientProxy_ReturnContentTypes_Tests() |
|||
{ |
|||
_controller = ServiceProvider.GetRequiredService<IRegularTestController>(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetPlainStringAsync_Should_Return_Unwrapped_String() |
|||
{ |
|||
var result = await _controller.GetPlainStringAsync(); |
|||
result.ShouldBe("Open"); |
|||
result.StartsWith("\"").ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetProducesJsonStringAsync_Should_Strip_Json_Quotes() |
|||
{ |
|||
var result = await _controller.GetProducesJsonStringAsync(); |
|||
result.ShouldBe("Open"); |
|||
result.StartsWith("\"").ShouldBeFalse(); |
|||
result.EndsWith("\"").ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetProducesTextStringAsync_Should_Return_Raw_Text_Body() |
|||
{ |
|||
var result = await _controller.GetProducesTextStringAsync(); |
|||
result.ShouldBe("Open"); |
|||
result.StartsWith("\"").ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetNullStringAsync_Should_Return_Default_Or_Empty() |
|||
{ |
|||
var result = await _controller.GetNullStringAsync(); |
|||
(result == null || result == string.Empty).ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetEmptyStringAsync_Should_Return_Empty() |
|||
{ |
|||
var result = await _controller.GetEmptyStringAsync(); |
|||
(result == null || result == string.Empty).ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetProducesJsonNullStringAsync_Should_Not_Return_Literal_Null() |
|||
{ |
|||
var result = await _controller.GetProducesJsonNullStringAsync(); |
|||
result.ShouldNotBe("null"); |
|||
(result == null || result == string.Empty).ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetEscapedStringAsync_Should_Decode_Escaped_Characters() |
|||
{ |
|||
var result = await _controller.GetEscapedStringAsync(); |
|||
result.ShouldBe("a\"b\\c\nd"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task DownloadIconAsync_Should_Return_Binary_Bytes() |
|||
{ |
|||
using var content = await _controller.DownloadIconAsync(); |
|||
using var ms = new MemoryStream(); |
|||
await content.GetStream().CopyToAsync(ms); |
|||
ms.ToArray().ShouldBe(System.Text.Encoding.UTF8.GetBytes("ICON-BYTES")); |
|||
content.FileName.ShouldBe("icon.bin"); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetReferenceTypeObjectAsync_Should_Not_Be_Wrapped_As_RemoteStreamContent() |
|||
{ |
|||
var result = await _controller.GetReferenceTypeObjectAsync(); |
|||
result.ShouldNotBeNull(); |
|||
result.ShouldNotBeAssignableTo<Volo.Abp.Content.IRemoteStreamContent>(); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task GetByteArrayAsync_Should_Round_Trip_Bytes() |
|||
{ |
|||
var bytes = await _controller.GetByteArrayAsync(); |
|||
bytes.ShouldBe(new byte[] { 1, 2, 3, 4 }); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Existing_IncrementValueAsync_Regression_Should_Still_Work() |
|||
{ |
|||
var result = await _controller.IncrementValueAsync(41); |
|||
result.ShouldBe(42); |
|||
} |
|||
} |
|||
@ -0,0 +1,279 @@ |
|||
using System.Collections.Generic; |
|||
using System.IO; |
|||
using System.Threading.Tasks; |
|||
using Shouldly; |
|||
using Volo.Abp.Content; |
|||
using Volo.Abp.Http.Modeling; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Http.Modeling; |
|||
|
|||
public class ReturnValueApiDescriptionModel_Tests |
|||
{ |
|||
[Fact] |
|||
public void Create_Without_ContentTypes_Should_Leave_Property_Null() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(string)); |
|||
|
|||
model.ShouldNotBeNull(); |
|||
model.TypeSimple.ShouldBe("string"); |
|||
model.ContentTypes.ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Create_With_ContentTypes_Should_Populate_The_Property() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create( |
|||
typeof(string), |
|||
new[] { "application/json", "text/plain" }); |
|||
|
|||
model.ContentTypes.ShouldNotBeNull(); |
|||
model.ContentTypes!.ShouldBe(new[] { "application/json", "text/plain" }); |
|||
} |
|||
} |
|||
|
|||
public class ReturnValueApiDescriptionModel_IsRemoteStream_Tests |
|||
{ |
|||
[Fact] |
|||
public void Direct_IRemoteStreamContent_Should_Be_True() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(IRemoteStreamContent)); |
|||
model.IsRemoteStream.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Concrete_RemoteStreamContent_Should_Be_True() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(RemoteStreamContent)); |
|||
model.IsRemoteStream.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Custom_Subclass_Of_IRemoteStreamContent_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(MyCustomStreamContent)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Task_Of_IRemoteStreamContent_Should_Be_True_After_UnwrapTask() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(Task<IRemoteStreamContent>)); |
|||
model.IsRemoteStream.ShouldBeTrue(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Task_Of_Custom_Stream_Subclass_Should_Be_False_After_UnwrapTask() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(Task<MyCustomStreamContent>)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void IRemoteStreamContent_Array_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(IRemoteStreamContent[])); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Concrete_RemoteStreamContent_Array_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(RemoteStreamContent[])); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void List_Of_IRemoteStreamContent_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(List<IRemoteStreamContent>)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void IEnumerable_Of_IRemoteStreamContent_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(IEnumerable<IRemoteStreamContent>)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void IReadOnlyCollection_Of_IRemoteStreamContent_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(IReadOnlyCollection<IRemoteStreamContent>)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Plain_String_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(string)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Int_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(int)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Plain_Dto_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(PlainDto)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Dto_Containing_IRemoteStreamContent_Property_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(DtoWithStream)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Dto_Inheriting_From_Type_With_Stream_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(DtoInheritingStream)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Byte_Array_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(byte[])); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Dictionary_Should_Be_False() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(Dictionary<string, int>)); |
|||
model.IsRemoteStream.ShouldBeFalse(); |
|||
} |
|||
|
|||
private class MyCustomStreamContent : IRemoteStreamContent |
|||
{ |
|||
public string? FileName => null; |
|||
public string? ContentType => null; |
|||
public long? ContentLength => null; |
|||
public Stream GetStream() => Stream.Null; |
|||
public void Dispose() { } |
|||
} |
|||
|
|||
private class PlainDto |
|||
{ |
|||
public int Id { get; set; } |
|||
public string Name { get; set; } = string.Empty; |
|||
} |
|||
|
|||
private class DtoWithStream |
|||
{ |
|||
public string FileName { get; set; } = string.Empty; |
|||
public IRemoteStreamContent? File { get; set; } |
|||
} |
|||
|
|||
private class DtoInheritingStream : PlainDto |
|||
{ |
|||
public IRemoteStreamContent? Stream { get; set; } |
|||
} |
|||
} |
|||
|
|||
public class ReturnValueApiDescriptionModel_BackwardsCompat_Tests |
|||
{ |
|||
[Fact] |
|||
public void Deserializing_Json_Without_ContentTypes_Field_Should_Leave_It_Null() |
|||
{ |
|||
var json = """
|
|||
{ |
|||
"type": "System.String", |
|||
"typeSimple": "string" |
|||
} |
|||
""";
|
|||
|
|||
var model = System.Text.Json.JsonSerializer.Deserialize<ReturnValueApiDescriptionModel>( |
|||
json, |
|||
new System.Text.Json.JsonSerializerOptions |
|||
{ |
|||
PropertyNameCaseInsensitive = true |
|||
}); |
|||
|
|||
model.ShouldNotBeNull(); |
|||
model!.Type.ShouldBe("System.String"); |
|||
model.TypeSimple.ShouldBe("string"); |
|||
model.ContentTypes.ShouldBeNull(); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Deserializing_Json_With_ContentTypes_Field_Should_Populate_It() |
|||
{ |
|||
var json = """
|
|||
{ |
|||
"type": "System.String", |
|||
"typeSimple": "string", |
|||
"contentTypes": ["application/json", "text/plain"] |
|||
} |
|||
""";
|
|||
|
|||
var model = System.Text.Json.JsonSerializer.Deserialize<ReturnValueApiDescriptionModel>( |
|||
json, |
|||
new System.Text.Json.JsonSerializerOptions |
|||
{ |
|||
PropertyNameCaseInsensitive = true |
|||
}); |
|||
|
|||
model!.ContentTypes.ShouldNotBeNull(); |
|||
model.ContentTypes!.ShouldBe(new[] { "application/json", "text/plain" }); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Serializing_With_Null_ContentTypes_Should_Emit_Null_Or_Omit() |
|||
{ |
|||
var model = ReturnValueApiDescriptionModel.Create(typeof(string)); |
|||
var json = System.Text.Json.JsonSerializer.Serialize(model); |
|||
|
|||
var deserialized = System.Text.Json.JsonSerializer.Deserialize<ReturnValueApiDescriptionModel>( |
|||
json, |
|||
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); |
|||
deserialized!.ContentTypes.ShouldBeNull(); |
|||
} |
|||
} |
|||
|
|||
public class ActionApiDescriptionModel_Tests |
|||
{ |
|||
[Fact] |
|||
public void Create_Should_Propagate_ReturnValueContentTypes() |
|||
{ |
|||
var method = typeof(ActionApiDescriptionModel_Tests).GetMethod(nameof(SampleMethod))!; |
|||
var model = ActionApiDescriptionModel.Create( |
|||
uniqueName: "SampleMethod", |
|||
method: method, |
|||
url: "api/test/sample", |
|||
httpMethod: "GET", |
|||
supportedVersions: new[] { "1.0" }, |
|||
allowAnonymous: true, |
|||
authorizeDatas: null, |
|||
implementFrom: null, |
|||
returnValueContentTypes: new[] { "application/octet-stream" }); |
|||
|
|||
model.ReturnValue.ContentTypes.ShouldNotBeNull(); |
|||
model.ReturnValue.ContentTypes!.ShouldBe(new[] { "application/octet-stream" }); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Create_Without_ReturnValueContentTypes_Should_Leave_Null() |
|||
{ |
|||
var method = typeof(ActionApiDescriptionModel_Tests).GetMethod(nameof(SampleMethod))!; |
|||
var model = ActionApiDescriptionModel.Create( |
|||
uniqueName: "SampleMethod", |
|||
method: method, |
|||
url: "api/test/sample", |
|||
httpMethod: "GET", |
|||
supportedVersions: new[] { "1.0" }); |
|||
|
|||
model.ReturnValue.ContentTypes.ShouldBeNull(); |
|||
} |
|||
|
|||
public string SampleMethod() => string.Empty; |
|||
} |
|||
@ -0,0 +1,266 @@ |
|||
#nullable enable |
|||
using System.Collections.Generic; |
|||
using Microsoft.Extensions.Options; |
|||
using Shouldly; |
|||
using Volo.Abp.Http.Modeling; |
|||
using Volo.Abp.Http.ProxyScripting.Generators; |
|||
using Volo.Abp.Http.ProxyScripting.Generators.JQuery; |
|||
using Xunit; |
|||
|
|||
namespace Volo.Abp.Http.ProxyScripting.Generators.JQuery; |
|||
|
|||
public class JQueryProxyScriptGenerator_ContentTypes_Tests |
|||
{ |
|||
private readonly JQueryProxyScriptGenerator _generator = new( |
|||
Microsoft.Extensions.Options.Options.Create(new DynamicJavaScriptProxyOptions())); |
|||
|
|||
[Fact] |
|||
public void Should_Emit_Json_DataType_And_Accept_When_ContentTypes_Contain_Json() |
|||
{ |
|||
var script = _generator.CreateScript(BuildAppModel( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "text/plain", "application/json", "text/json" })); |
|||
|
|||
script.ShouldContain("dataType: 'json'"); |
|||
script.ShouldContain("Accept: 'application/json'"); |
|||
script.ShouldNotContain("dataType: 'text'"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Emit_Text_DataType_And_Accept_When_ContentTypes_Only_Text() |
|||
{ |
|||
var script = _generator.CreateScript(BuildAppModel( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "text/plain", "text/csv" })); |
|||
|
|||
script.ShouldContain("dataType: 'text'"); |
|||
script.ShouldContain("Accept: 'text/plain'"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Fallback_To_Legacy_Text_When_Return_Is_String_And_ContentTypes_Null() |
|||
{ |
|||
var script = _generator.CreateScript(BuildAppModel( |
|||
returnType: "System.String", |
|||
contentTypes: null)); |
|||
|
|||
// Legacy behavior preserved: string return → dataType: 'text' (no Accept override)
|
|||
script.ShouldContain("dataType: 'text'"); |
|||
script.ShouldNotContain("Accept:"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Emit_No_DataType_For_Non_String_Return_Without_ContentTypes() |
|||
{ |
|||
var script = _generator.CreateScript(BuildAppModel( |
|||
returnType: "System.Int32", |
|||
contentTypes: null)); |
|||
|
|||
script.ShouldNotContain("dataType: 'text'"); |
|||
script.ShouldNotContain("dataType: 'json'"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Not_Emit_Json_DataType_When_Only_Binary_ContentTypes() |
|||
{ |
|||
var script = _generator.CreateScript(BuildAppModel( |
|||
returnType: "System.Byte[]", |
|||
contentTypes: new[] { "application/octet-stream", "image/png" })); |
|||
|
|||
// jQuery dataType doesn't have a clean "blob" — fall through to no override
|
|||
script.ShouldNotContain("dataType: 'json'"); |
|||
script.ShouldNotContain("dataType: 'text'"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_Prefer_Json_When_Json_Present_Even_With_Other_Types() |
|||
{ |
|||
var script = _generator.CreateScript(BuildAppModel( |
|||
returnType: "My.Project.UserDto", |
|||
contentTypes: new[] { "application/xml", "application/json", "text/html" })); |
|||
|
|||
script.ShouldContain("dataType: 'json'"); |
|||
script.ShouldContain("Accept: 'application/json'"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Case_Insensitive_Json_Detection() |
|||
{ |
|||
var script = _generator.CreateScript(BuildAppModel( |
|||
returnType: "System.String", |
|||
contentTypes: new[] { "APPLICATION/JSON" })); |
|||
|
|||
script.ShouldContain("dataType: 'json'"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void IsRemoteStream_Should_Skip_DataType_Override_To_Avoid_JSON_Metadata_Regression() |
|||
{ |
|||
// 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" }, |
|||
isRemoteStream: true); |
|||
|
|||
var script = _generator.CreateScript(model); |
|||
|
|||
script.ShouldNotContain("dataType: '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"); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Multiple_Direct_FormFile_Params_Should_Forward_Only_First_Var_Known_Limitation() |
|||
{ |
|||
var script = _generator.CreateScript(BuildUploadModel( |
|||
uploadParameters: new[] |
|||
{ |
|||
("file1", "file1", ParameterBindingSources.FormFile), |
|||
("file2", "file2", ParameterBindingSources.FormFile), |
|||
})); |
|||
|
|||
script.ShouldContain("data: file1"); |
|||
script.ShouldNotContain("data: file2"); |
|||
script.ShouldNotContain("$.merge(file1, file2)"); |
|||
} |
|||
|
|||
private static ApplicationApiDescriptionModel BuildAppModel(string returnType, IList<string>? contentTypes, bool isRemoteStream = false) |
|||
{ |
|||
var model = ApplicationApiDescriptionModel.Create(); |
|||
var module = model.GetOrAddModule("app", "Default"); |
|||
var controller = module.GetOrAddController( |
|||
name: "TestController", |
|||
groupName: null, |
|||
isRemoteService: true, |
|||
isIntegrationService: false, |
|||
apiVersion: null, |
|||
type: typeof(object)); |
|||
|
|||
var action = new ActionApiDescriptionModel |
|||
{ |
|||
UniqueName = "DoSomethingAsync", |
|||
Name = "DoSomethingAsync", |
|||
HttpMethod = "GET", |
|||
Url = "api/test/do-something", |
|||
SupportedVersions = new List<string>(), |
|||
ParametersOnMethod = new List<MethodParameterApiDescriptionModel>(), |
|||
Parameters = new List<ParameterApiDescriptionModel>(), |
|||
ReturnValue = new ReturnValueApiDescriptionModel |
|||
{ |
|||
Type = returnType, |
|||
TypeSimple = returnType, |
|||
ContentTypes = contentTypes, |
|||
IsRemoteStream = isRemoteStream, |
|||
}, |
|||
AuthorizeDatas = new List<AuthorizeDataApiDescriptionModel>(), |
|||
}; |
|||
controller.AddAction("DoSomethingAsync", action); |
|||
|
|||
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; |
|||
} |
|||
} |
|||
@ -0,0 +1,846 @@ |
|||
import { readFileSync } from 'fs'; |
|||
import { join } from 'path'; |
|||
import { describe, expect, test } from 'vitest'; |
|||
import { eBindingSourceId } from '../enums'; |
|||
import { Action } from '../models'; |
|||
import { |
|||
createActionToBodyMapper, |
|||
createActionToMethodMapper, |
|||
createActionToSignatureMapper, |
|||
} from '../utils/service'; |
|||
|
|||
const TEMPLATE_PATH = join( |
|||
__dirname, |
|||
'..', |
|||
'commands', |
|||
'api', |
|||
'files-service', |
|||
'proxy', |
|||
'__namespace@dir__', |
|||
'__name@kebab__.service.ts.template', |
|||
); |
|||
|
|||
function buildAction(overrides: Partial<Action>): Action { |
|||
return { |
|||
uniqueName: 'GetStatusAsync', |
|||
name: 'GetStatus', |
|||
httpMethod: 'GET', |
|||
url: 'api/app/test-service/status', |
|||
supportedVersions: [], |
|||
parametersOnMethod: [], |
|||
parameters: [], |
|||
returnValue: { type: 'System.String', typeSimple: 'string' }, |
|||
...overrides, |
|||
} as Action; |
|||
} |
|||
|
|||
describe('createActionToBodyMapper — string return value', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test('without contentTypes falls back to text mode (legacy behavior)', () => { |
|||
const body = mapBody(buildAction({})); |
|||
|
|||
expect(body.responseType).toBe('string'); |
|||
expect(body.httpResponseType).toBe('text'); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
}); |
|||
|
|||
test('with contentTypes containing application/json picks json + Accept: application/json', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['application/json', 'text/plain'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
expect(body.acceptHeader).toBe('application/json'); |
|||
}); |
|||
|
|||
test('with only text/* contentTypes picks text + Accept: text/plain', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['text/plain', 'text/csv'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('text'); |
|||
expect(body.acceptHeader).toBe('text/plain'); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToBodyMapper — IRemoteStreamContent return value', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test('always picks blob + Accept: application/octet-stream regardless of contentTypes', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'Volo.Abp.Content.IRemoteStreamContent', |
|||
typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
expect(body.acceptHeader).toBe('application/octet-stream'); |
|||
expect(body.isBlobMethod()).toBe(true); |
|||
}); |
|||
|
|||
test('binary-only contentTypes picks blob and echoes back the actual binary media type', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.Byte[]', |
|||
typeSimple: 'byte[]', |
|||
contentTypes: ['application/pdf'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
expect(body.acceptHeader).toBe('application/pdf'); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToBodyMapper — other return values', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test('object return without contentTypes has no httpResponseType / acceptHeader (defaults to json)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { type: 'My.Project.UserDto', typeSimple: 'My.Project.UserDto' }, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBeUndefined(); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
}); |
|||
|
|||
test('void return has no httpResponseType / acceptHeader', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { type: 'System.Void', typeSimple: 'void' }, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBeUndefined(); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
}); |
|||
|
|||
test('registers a query parameter via the binding source', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
parameters: [ |
|||
{ |
|||
nameOnMethod: 'id', |
|||
name: 'id', |
|||
jsonName: null, |
|||
type: 'System.Guid', |
|||
typeSimple: 'string', |
|||
isOptional: false, |
|||
defaultValue: null, |
|||
constraintTypes: null, |
|||
bindingSourceId: eBindingSourceId.Query, |
|||
descriptorName: '', |
|||
}, |
|||
], |
|||
}), |
|||
); |
|||
|
|||
expect(body.params).toEqual(['id']); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToBodyMapper — IsRemoteStream backend flag', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test('isRemoteStream=true forces blob even if Type is a custom subclass name', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'My.Project.CustomStreamContent', |
|||
typeSimple: 'My.Project.CustomStreamContent', |
|||
isRemoteStream: true, |
|||
contentTypes: ['text/plain', 'application/json'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
expect(body.acceptHeader).toBe('application/octet-stream'); |
|||
expect(body.isBlobMethod()).toBe(true); |
|||
}); |
|||
|
|||
test('[Volo.Abp.Content.IRemoteStreamContent] (real ABP square-bracket form) must NOT pick blob and degrades responseType to any[]', () => { |
|||
// ABP serialises collections as `[T]` (not `T[]`) — pin the on-the-wire shape.
|
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.Collections.Generic.IList<Volo.Abp.Content.IRemoteStreamContent>', |
|||
typeSimple: '[Volo.Abp.Content.IRemoteStreamContent]', |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBeUndefined(); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
expect(body.isBlobMethod()).toBe(false); |
|||
expect(body.responseType).toBe('any[]'); |
|||
}); |
|||
|
|||
test('IRemoteStreamContent[] array return must NOT pick blob (server falls back to JSON metadata)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'Volo.Abp.Content.IRemoteStreamContent[]', |
|||
typeSimple: 'Volo.Abp.Content.IRemoteStreamContent[]', |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBeUndefined(); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
expect(body.isBlobMethod()).toBe(false); |
|||
expect(body.responseType).toBe('any[]'); |
|||
}); |
|||
|
|||
test('isRemoteStream=false with stream-content type-name still detected by type name (legacy)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'Volo.Abp.Content.IRemoteStreamContent', |
|||
typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
expect(body.acceptHeader).toBe('application/octet-stream'); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToBodyMapper — +json suffix detection', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test('application/problem+json echoes back as Accept (decoder stays json)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['application/problem+json'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
expect(body.acceptHeader).toBe('application/problem+json'); |
|||
}); |
|||
|
|||
test('text/json echoes back as text/json (decoder stays json)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['text/json'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
expect(body.acceptHeader).toBe('text/json'); |
|||
}); |
|||
|
|||
test('application/vnd.api+json echoes back as Accept (decoder stays json)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['application/vnd.api+json'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
expect(body.acceptHeader).toBe('application/vnd.api+json'); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToBodyMapper — expanded binary whitelist', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test.each([ |
|||
['application/wasm'], |
|||
['font/woff2'], |
|||
['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], |
|||
['application/vnd.ms-excel'], |
|||
['application/vnd.oasis.opendocument.spreadsheet'], |
|||
['application/x-msdownload'], |
|||
['application/rtf'], |
|||
['application/x-rar-compressed'], |
|||
['application/x-bzip2'], |
|||
['application/x-iso9660-image'], |
|||
['application/java-archive'], |
|||
['application/epub+zip'], |
|||
['model/gltf-binary'], |
|||
])('"%s" picked as blob', mediaType => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.Byte[]', |
|||
typeSimple: 'byte[]', |
|||
contentTypes: [mediaType], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToBodyMapper — contentTypes precedence and edge cases', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test('isBlobMethod() type detection wins over json in contentTypes', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'Volo.Abp.Content.RemoteStreamContent', |
|||
typeSimple: 'Volo.Abp.Content.RemoteStreamContent', |
|||
contentTypes: ['application/json', 'text/plain'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
expect(body.acceptHeader).toBe('application/octet-stream'); |
|||
}); |
|||
|
|||
test('IRemoteStreamContent[] array falls through to defaults (server returns JSON metadata, not binary)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'Volo.Abp.Content.IRemoteStreamContent[]', |
|||
typeSimple: 'Volo.Abp.Content.IRemoteStreamContent[]', |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBeUndefined(); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
}); |
|||
|
|||
test('case-insensitive json detection (APPLICATION/JSON)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['APPLICATION/JSON'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
expect(body.acceptHeader).toBe('application/json'); |
|||
}); |
|||
|
|||
test('image/* contentTypes alone picks blob and echoes back the first image type', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.Byte[]', |
|||
typeSimple: 'byte[]', |
|||
contentTypes: ['image/png', 'image/jpeg'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
expect(body.acceptHeader).toBe('image/png'); |
|||
}); |
|||
|
|||
test('video/* and audio/* picked as blob', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.Byte[]', |
|||
typeSimple: 'byte[]', |
|||
contentTypes: ['video/mp4', 'audio/mpeg'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
}); |
|||
|
|||
test('application/pdf contentTypes picked as blob', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.Byte[]', |
|||
typeSimple: 'byte[]', |
|||
contentTypes: ['application/pdf'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
}); |
|||
|
|||
test('empty contentTypes falls through to legacy string→text behavior', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { type: 'System.String', typeSimple: 'string', contentTypes: [] }, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('text'); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
}); |
|||
|
|||
test('mixed text/* and application/json picks json and echoes the first json-shaped media type', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['text/json', 'text/plain', 'application/json'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
expect(body.acceptHeader).toBe('text/json'); |
|||
}); |
|||
|
|||
test('contentTypes with json-suffix variants still picks json', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['application/json; charset=utf-8'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
}); |
|||
|
|||
test('non-string non-blob type with contentTypes containing json defaults appropriately', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'My.Project.UserDto', |
|||
typeSimple: 'My.Project.UserDto', |
|||
contentTypes: ['application/json'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
expect(body.acceptHeader).toBe('application/json'); |
|||
}); |
|||
|
|||
test('json contentType with charset parameter is normalized', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['application/json; charset=utf-8'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
expect(body.acceptHeader).toBe('application/json'); |
|||
}); |
|||
|
|||
test('text contentType with charset parameter is normalized', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['text/plain ; charset=utf-8 '], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('text'); |
|||
expect(body.acceptHeader).toBe('text/plain'); |
|||
}); |
|||
|
|||
test('mixed text/plain (with charset) and application/json picks json after normalize', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['text/plain; charset=utf-8', 'application/json; charset=utf-8'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('json'); |
|||
}); |
|||
|
|||
test('text/csv only (custom text format) picks text and echoes the content type back', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'System.String', |
|||
typeSimple: 'string', |
|||
contentTypes: ['text/csv'], |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('text'); |
|||
expect(body.acceptHeader).toBe('text/csv'); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToBodyMapper — backward compatibility', () => { |
|||
const mapBody = createActionToBodyMapper(); |
|||
|
|||
test('legacy api-definition without contentTypes works (string)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { type: 'System.String', typeSimple: 'string' } as Partial<Action>['returnValue'], |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('text'); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
}); |
|||
|
|||
test('legacy api-definition without contentTypes works (IRemoteStreamContent)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { |
|||
type: 'Volo.Abp.Content.IRemoteStreamContent', |
|||
typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', |
|||
}, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBe('blob'); |
|||
expect(body.acceptHeader).toBe('application/octet-stream'); |
|||
}); |
|||
|
|||
test('legacy api-definition without contentTypes works (object)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { type: 'My.Project.UserDto', typeSimple: 'My.Project.UserDto' }, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBeUndefined(); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
}); |
|||
|
|||
test('void / no return preserves legacy behavior', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
returnValue: { type: 'System.Void', typeSimple: 'void' }, |
|||
} as Partial<Action>), |
|||
); |
|||
|
|||
expect(body.httpResponseType).toBeUndefined(); |
|||
expect(body.acceptHeader).toBeUndefined(); |
|||
}); |
|||
}); |
|||
|
|||
describe('proxy service template emission', () => { |
|||
const template = readFileSync(TEMPLATE_PATH, 'utf8'); |
|||
|
|||
test('reads body.httpResponseType and body.acceptHeader from body', () => { |
|||
expect(template).toContain('body.httpResponseType'); |
|||
expect(template).toContain('body.acceptHeader'); |
|||
}); |
|||
|
|||
test('emits Accept header conditional', () => { |
|||
expect(template).toMatch(/headers:\s*\{\s*Accept:/); |
|||
}); |
|||
|
|||
test('emits responseType only for non-json httpResponseType', () => { |
|||
expect(template).toMatch(/httpResponseType\s*&&\s*httpResponseType\s*!==\s*'json'/); |
|||
}); |
|||
}); |
|||
|
|||
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'); |
|||
}); |
|||
|
|||
test('Multiple direct FormFile method args: body forwards only the first var (known limitation, mirrors jQuery)', () => { |
|||
const body = mapBody( |
|||
buildAction({ |
|||
httpMethod: 'POST', |
|||
url: 'api/proxy-demo/media/upload-two-direct', |
|||
parameters: [ |
|||
{ nameOnMethod: 'file1', name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
{ nameOnMethod: 'file2', name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>), |
|||
); |
|||
expect(body.body).toBe('file1'); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToSignatureMapper — multipart upload signature collapse', () => { |
|||
const mapSignature = createActionToSignatureMapper(); |
|||
|
|||
test('FormFile DTO method arg collapses to FormData type', () => { |
|||
const sig = mapSignature(buildAction({ |
|||
httpMethod: 'POST', |
|||
url: 'api/app/proxy-demo-test/upload-single', |
|||
parametersOnMethod: [ |
|||
{ name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, |
|||
], |
|||
parameters: [ |
|||
{ nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, |
|||
{ nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>)); |
|||
const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); |
|||
expect(types).toContain('input:FormData'); |
|||
expect(types[types.length - 1]).toBe('config:Partial<Rest.Config>'); |
|||
}); |
|||
|
|||
test('Path arg keeps its primitive type while upload arg becomes FormData', () => { |
|||
const sig = mapSignature(buildAction({ |
|||
httpMethod: 'POST', |
|||
url: 'api/proxy-demo/media/upload-with-path/{id}', |
|||
parametersOnMethod: [ |
|||
{ name: 'id', type: 'System.Int32', typeAsString: 'System.Int32', typeSimple: 'number', isOptional: false, defaultValue: null } as any, |
|||
{ name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, |
|||
], |
|||
parameters: [ |
|||
{ nameOnMethod: 'id', name: 'id', type: 'System.Int32', typeSimple: 'int', bindingSourceId: eBindingSourceId.Path } as any, |
|||
{ nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, |
|||
{ nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>)); |
|||
const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); |
|||
expect(types).toEqual([ |
|||
'id:number', |
|||
'input:FormData', |
|||
'config:Partial<Rest.Config>', |
|||
]); |
|||
}); |
|||
|
|||
test('Multiple direct IRemoteStreamContent method args each become FormData independently', () => { |
|||
const sig = mapSignature(buildAction({ |
|||
httpMethod: 'POST', |
|||
url: 'api/proxy-demo/media/upload-two-direct', |
|||
parametersOnMethod: [ |
|||
{ name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeAsString: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', isOptional: false, defaultValue: null } as any, |
|||
{ name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeAsString: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', isOptional: false, defaultValue: null } as any, |
|||
], |
|||
parameters: [ |
|||
{ nameOnMethod: 'file1', name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
{ nameOnMethod: 'file2', name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>)); |
|||
const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); |
|||
expect(types).toEqual([ |
|||
'file1:FormData', |
|||
'file2:FormData', |
|||
'config:Partial<Rest.Config>', |
|||
]); |
|||
}); |
|||
|
|||
test('Non-upload action signature stays untouched (regression guard)', () => { |
|||
const sig = mapSignature(buildAction({ |
|||
httpMethod: 'GET', |
|||
url: 'api/app/proxy-demo-test/get-item-by-id', |
|||
parametersOnMethod: [ |
|||
{ name: 'id', type: 'System.Int32', typeAsString: 'System.Int32', typeSimple: 'number', isOptional: false, defaultValue: null } as any, |
|||
], |
|||
parameters: [ |
|||
{ nameOnMethod: 'id', name: 'id', type: 'System.Int32', typeSimple: 'int', bindingSourceId: eBindingSourceId.Path } as any, |
|||
], |
|||
} as Partial<Action>)); |
|||
const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); |
|||
expect(types).toEqual(['id:number', 'config:Partial<Rest.Config>']); |
|||
}); |
|||
}); |
|||
|
|||
describe('createActionToMethodMapper — signature + body wired together for upload actions', () => { |
|||
const mapMethod = createActionToMethodMapper(); |
|||
|
|||
test('Upload action produces both FormData signature and FormData body in one pass', () => { |
|||
const method = mapMethod(buildAction({ |
|||
httpMethod: 'POST', |
|||
url: 'api/app/proxy-demo-test/upload-single', |
|||
parametersOnMethod: [ |
|||
{ name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, |
|||
], |
|||
parameters: [ |
|||
{ nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, |
|||
{ nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>)); |
|||
const sigTypes = method.signature.parameters.map((p: any) => `${p.name}:${p.type}`); |
|||
expect(sigTypes).toContain('input:FormData'); |
|||
expect(method.body.body).toBe('input'); |
|||
expect(method.body.params.join(',')).not.toContain('name'); |
|||
}); |
|||
|
|||
test('Query + ModelBinding + FormFile mix preserves Query as URL param and drops upload-arg fields', () => { |
|||
const method = mapMethod(buildAction({ |
|||
httpMethod: 'POST', |
|||
url: 'api/proxy-demo/media/upload-with-query', |
|||
parametersOnMethod: [ |
|||
{ name: 'tag', type: 'System.String', typeAsString: 'System.String', typeSimple: 'string', isOptional: false, defaultValue: null } as any, |
|||
{ name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, |
|||
], |
|||
parameters: [ |
|||
{ nameOnMethod: 'tag', name: 'tag', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Query } as any, |
|||
{ nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, |
|||
{ nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, |
|||
], |
|||
} as Partial<Action>)); |
|||
const sigTypes = method.signature.parameters.map((p: any) => `${p.name}:${p.type}`); |
|||
expect(sigTypes).toEqual(['tag:string', 'input:FormData', 'config:Partial<Rest.Config>']); |
|||
expect(method.body.body).toBe('input'); |
|||
expect(method.body.params).toContain('tag'); |
|||
expect(method.body.params.join(',')).not.toContain('name'); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,495 @@ |
|||
import { readFileSync } from 'fs'; |
|||
import { join } from 'path'; |
|||
import { template as lodashTemplate } from 'lodash'; |
|||
import { describe, expect, test } from 'vitest'; |
|||
|
|||
/** |
|||
* Smoke test that actually renders the proxy `.service.ts.template` against |
|||
* representative body configurations and asserts the emitted code matches |
|||
* what the runtime contract requires. |
|||
* |
|||
* This catches template-syntax / control-flow regressions that a string |
|||
* `toContain` check on the template source would silently let through. |
|||
*/ |
|||
|
|||
const TEMPLATE_PATH = join( |
|||
__dirname, |
|||
'..', |
|||
'commands', |
|||
'api', |
|||
'files-service', |
|||
'proxy', |
|||
'__namespace@dir__', |
|||
'__name@kebab__.service.ts.template', |
|||
); |
|||
|
|||
const TEMPLATE_SRC = readFileSync(TEMPLATE_PATH, 'utf8'); |
|||
|
|||
function render(context: Record<string, unknown>): string { |
|||
const compiled = lodashTemplate(TEMPLATE_SRC, { |
|||
imports: { |
|||
camel: (s: string) => s.charAt(0).toLowerCase() + s.slice(1), |
|||
serializeParameters: (params: Array<{ name: string; type: string; default?: string }>) => |
|||
params.map(p => `${p.name}: ${p.type}`).join(', '), |
|||
}, |
|||
}); |
|||
return compiled(context); |
|||
} |
|||
|
|||
function buildContext(body: Partial<MockBody>) { |
|||
return { |
|||
apiName: 'Default', |
|||
name: 'Sample', |
|||
namespace: 'app', |
|||
imports: [ |
|||
{ keyword: 'import', specifiers: ['RestService', 'Rest'], path: '@abp/ng.core' }, |
|||
{ keyword: 'import', specifiers: ['Injectable', 'inject'], path: '@angular/core' }, |
|||
], |
|||
methods: [ |
|||
{ |
|||
body: makeBody(body), |
|||
signature: { |
|||
name: 'GetSampleAsync', |
|||
parameters: [], |
|||
}, |
|||
}, |
|||
], |
|||
}; |
|||
} |
|||
|
|||
interface MockBody { |
|||
method: string; |
|||
url: string; |
|||
responseType: string; |
|||
responseTypeWithNamespace: string; |
|||
httpResponseType?: string; |
|||
acceptHeader?: string; |
|||
body?: string; |
|||
params: string[]; |
|||
dictParamVar?: string; |
|||
requestType: string; |
|||
isBlobMethod(): boolean; |
|||
} |
|||
|
|||
function makeBody(overrides: Partial<MockBody>): MockBody { |
|||
return { |
|||
method: 'GET', |
|||
url: "'/api/sample'", |
|||
responseType: 'any', |
|||
responseTypeWithNamespace: 'any', |
|||
httpResponseType: undefined, |
|||
acceptHeader: undefined, |
|||
body: undefined, |
|||
params: [], |
|||
dictParamVar: undefined, |
|||
requestType: 'any', |
|||
isBlobMethod: () => false, |
|||
...overrides, |
|||
}; |
|||
} |
|||
|
|||
describe('proxy service template — rendered output', () => { |
|||
test('default JSON body emits no responseType and no Accept header', () => { |
|||
const output = render(buildContext({ |
|||
responseType: 'MyDto', |
|||
responseTypeWithNamespace: 'My.Project.MyDto', |
|||
})); |
|||
|
|||
expect(output).toContain("method: 'GET'"); |
|||
expect(output).not.toContain('responseType:'); |
|||
expect(output).not.toContain('headers:'); |
|||
}); |
|||
|
|||
test('json httpResponseType emits Accept but no responseType (default is json)', () => { |
|||
const output = render(buildContext({ |
|||
responseType: 'string', |
|||
responseTypeWithNamespace: 'string', |
|||
httpResponseType: 'json', |
|||
acceptHeader: 'application/json', |
|||
})); |
|||
|
|||
expect(output).toContain("headers: { Accept: 'application/json' }"); |
|||
expect(output).not.toContain('responseType:'); |
|||
}); |
|||
|
|||
test('text httpResponseType emits both responseType and Accept', () => { |
|||
const output = render(buildContext({ |
|||
responseType: 'string', |
|||
responseTypeWithNamespace: 'string', |
|||
httpResponseType: 'text', |
|||
acceptHeader: 'text/plain', |
|||
})); |
|||
|
|||
expect(output).toContain("responseType: 'text'"); |
|||
expect(output).toContain("headers: { Accept: 'text/plain' }"); |
|||
}); |
|||
|
|||
test('blob (IRemoteStreamContent) emits Blob return type + responseType + Accept', () => { |
|||
const output = render(buildContext({ |
|||
responseType: 'Volo.Abp.Content.IRemoteStreamContent', |
|||
responseTypeWithNamespace: 'Volo.Abp.Content.IRemoteStreamContent', |
|||
isBlobMethod: () => true, |
|||
httpResponseType: 'blob', |
|||
acceptHeader: 'application/octet-stream', |
|||
})); |
|||
|
|||
expect(output).toContain("responseType: 'blob'"); |
|||
expect(output).toContain('Blob>'); |
|||
expect(output).toContain("headers: { Accept: 'application/octet-stream' }"); |
|||
}); |
|||
|
|||
test('IRemoteStreamContent[] degradation renders any[] return type and does not reference IRemoteStreamContent', () => { |
|||
const output = render(buildContext({ |
|||
responseType: 'any[]', |
|||
responseTypeWithNamespace: '[Volo.Abp.Content.IRemoteStreamContent]', |
|||
isBlobMethod: () => false, |
|||
httpResponseType: undefined, |
|||
acceptHeader: undefined, |
|||
})); |
|||
|
|||
expect(output).toContain('any[]'); |
|||
expect(output).not.toContain('IRemoteStreamContent'); |
|||
expect(output).not.toContain("responseType: 'blob'"); |
|||
}); |
|||
|
|||
test('arraybuffer httpResponseType emits responseType', () => { |
|||
const output = render(buildContext({ |
|||
responseType: 'ArrayBuffer', |
|||
responseTypeWithNamespace: 'ArrayBuffer', |
|||
httpResponseType: 'arraybuffer', |
|||
acceptHeader: 'application/octet-stream', |
|||
})); |
|||
|
|||
expect(output).toContain("responseType: 'arraybuffer'"); |
|||
expect(output).toContain("headers: { Accept: 'application/octet-stream' }"); |
|||
}); |
|||
|
|||
test('no acceptHeader → no headers line', () => { |
|||
const output = render(buildContext({ |
|||
responseType: 'string', |
|||
responseTypeWithNamespace: 'string', |
|||
httpResponseType: 'text', |
|||
acceptHeader: undefined, |
|||
})); |
|||
|
|||
expect(output).toContain("responseType: 'text'"); |
|||
expect(output).not.toContain('headers:'); |
|||
}); |
|||
|
|||
test('rendered service code is valid TypeScript-shaped (closing braces / semicolons)', () => { |
|||
const output = render(buildContext({ |
|||
responseType: 'string', |
|||
responseTypeWithNamespace: 'string', |
|||
httpResponseType: 'json', |
|||
acceptHeader: 'application/json', |
|||
})); |
|||
|
|||
expect(output).toContain('@Injectable({'); |
|||
expect(output).toContain('providedIn: \'root\''); |
|||
expect(output).toContain('export class SampleService'); |
|||
expect(output).toContain('this.restService.request<any,'); |
|||
expect(output.match(/}/g)!.length).toBeGreaterThanOrEqual(3); |
|||
}); |
|||
|
|||
test.each([ |
|||
{ name: 'string + json Accept', body: { responseType: 'string', responseTypeWithNamespace: 'string', httpResponseType: 'json', acceptHeader: 'application/problem+json' } }, |
|||
{ name: 'string + text Accept', body: { responseType: 'string', responseTypeWithNamespace: 'string', httpResponseType: 'text', acceptHeader: 'text/csv' } }, |
|||
{ name: 'blob + pdf Accept', body: { responseType: 'Blob', responseTypeWithNamespace: 'Blob', httpResponseType: 'blob', acceptHeader: 'application/pdf', isBlobMethod: () => true } }, |
|||
{ name: 'any[] degradation', body: { responseType: 'any[]', responseTypeWithNamespace: '[Volo.Abp.Content.IRemoteStreamContent]' } }, |
|||
{ name: 'xml Accept only', body: { responseType: 'any', responseTypeWithNamespace: 'any', acceptHeader: 'application/xml' } }, |
|||
])('rendered service compiles cleanly under real ts.Program ($name)', ({ body }) => { |
|||
const ts = require('typescript'); |
|||
const ctx = buildContext(body as Partial<MockBody>); |
|||
ctx.methods[0].signature.parameters = [{ name: 'config', type: 'Record<string, any>' } as any]; |
|||
const output = render(ctx); |
|||
|
|||
const abpStub = ` |
|||
declare module '@abp/ng.core' { |
|||
export namespace Rest { |
|||
export interface Config { |
|||
apiName?: string; |
|||
observe?: any; |
|||
skipHandleError?: boolean; |
|||
responseType?: string; |
|||
[key: string]: any; |
|||
} |
|||
export type Observe = any; |
|||
} |
|||
export class RestService { |
|||
request<TBody, TResponse>(req: any, config?: any): import('rxjs').Observable<TResponse>; |
|||
} |
|||
} |
|||
`;
|
|||
const domStub = 'declare class Blob { constructor(parts?: any[], options?: any); }\n'; |
|||
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 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) => { |
|||
if (sources[fileName]) { |
|||
return ts.createSourceFile(fileName, sources[fileName], languageVersion, true); |
|||
} |
|||
return baseHost.getSourceFile(fileName, languageVersion, onError); |
|||
}, |
|||
fileExists: (fileName: string) => |
|||
sources[fileName] != null || baseHost.fileExists(fileName), |
|||
readFile: (fileName: string) => |
|||
sources[fileName] ?? baseHost.readFile(fileName), |
|||
}; |
|||
|
|||
const program = ts.createProgram(Object.keys(sources), compilerOptions, host); |
|||
const errors = ts |
|||
.getPreEmitDiagnostics(program) |
|||
.filter((d: any) => |
|||
d.category === ts.DiagnosticCategory.Error && |
|||
d.code !== 6053, |
|||
); |
|||
|
|||
if (errors.length) { |
|||
const messages = errors |
|||
.map((d: any) => { |
|||
const where = d.file |
|||
? (() => { |
|||
const p = d.file.getLineAndCharacterOfPosition(d.start ?? 0); |
|||
const lineText = d.file.text.split('\n')[p.line]; |
|||
return `${d.file.fileName}:${p.line + 1}:${p.character + 1}\n>>> ${lineText}\n>>> ${' '.repeat(p.character)}^`; |
|||
})() |
|||
: '(no file)'; |
|||
return `[${where}] TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`; |
|||
}) |
|||
.join('\n---\n'); |
|||
throw new Error(`Generated proxy did not compile:\n${output}\n=== diagnostics ===\n${messages}`); |
|||
} |
|||
expect(errors).toHaveLength(0); |
|||
}); |
|||
|
|||
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'); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue