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