diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs index 464981cb3c3..bd5e1e82a04 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs @@ -175,7 +175,8 @@ private async Task AddApiDescriptionToModelAsync( GetSupportedVersions(controllerType, method, setting), allowAnonymous, authorizeModels, - implementFrom + implementFrom, + GetReturnValueContentTypes(apiDescription) ) ); @@ -199,6 +200,27 @@ private async Task AddApiDescriptionToModelAsync( } } + private static List? GetReturnValueContentTypes(ApiDescription apiDescription) + { + var preferred = apiDescription.SupportedResponseTypes + .FirstOrDefault(x => x.StatusCode == 200 && x.ApiResponseFormats.Any()) + ?? apiDescription.SupportedResponseTypes + .FirstOrDefault(x => x.StatusCode is >= 200 and < 300 && x.ApiResponseFormats.Any()); + + if (preferred == null) + { + return null; + } + + var contentTypes = preferred.ApiResponseFormats + .Select(f => f.MediaType) + .Where(m => !string.IsNullOrWhiteSpace(m)) + .Distinct() + .ToList(); + + return contentTypes.Count > 0 ? contentTypes : null; + } + private static List GetSupportedVersions(Type controllerType, MethodInfo method, ConventionalControllerSetting? setting) { diff --git a/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs b/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs index 107008deea1..0b7a21c71e1 100644 --- a/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs +++ b/framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs @@ -108,8 +108,7 @@ protected virtual async Task RequestAsync(ClientProxyRequestContext reques { var responseContent = await RequestAsync(requestContext); - if (typeof(T) == typeof(IRemoteStreamContent) || - typeof(T) == typeof(RemoteStreamContent)) + if (typeof(T) == typeof(IRemoteStreamContent) || typeof(T) == typeof(RemoteStreamContent)) { /* returning a class that holds a reference to response * content just to be sure that GC does not dispose of @@ -127,7 +126,8 @@ await responseContent.ReadAsStreamAsync(), var stringContent = await responseContent.ReadAsStringAsync(); if (typeof(T) == typeof(string)) { - return (T)(object)stringContent; + var unwrapped = UnwrapStringResponse(stringContent, responseContent.Headers?.ContentType?.MediaType); + return (T)(object)unwrapped!; } if (stringContent.IsNullOrWhiteSpace()) @@ -139,6 +139,39 @@ await responseContent.ReadAsStreamAsync(), } } + protected virtual string? UnwrapStringResponse(string body, string? contentType) + { + if (body.IsNullOrEmpty() || contentType.IsNullOrWhiteSpace()) + { + return body; + } + + if (!IsJsonMediaType(NormalizeMediaType(contentType!))) + { + return body; + } + + try + { + var parsed = JsonSerializer.Deserialize(body); + return parsed ?? string.Empty; + } + catch + { + return body; + } + } + + protected static string NormalizeMediaType(string mediaType) + { + if (mediaType.IsNullOrWhiteSpace()) + { + return string.Empty; + } + var semi = mediaType.IndexOf(';'); + return (semi < 0 ? mediaType : mediaType.Substring(0, semi)).Trim().ToLowerInvariant(); + } + protected virtual async Task RequestAsync(ClientProxyRequestContext requestContext) { var clientConfig = ClientOptions.Value.HttpClientProxies.GetOrDefault(requestContext.ServiceType) ?? throw new AbpException($"Could not get HttpClientProxyConfig for {requestContext.ServiceType.FullName}."); @@ -326,14 +359,7 @@ protected virtual void AddHeaders( HttpRequestMessage requestMessage, ApiVersionInfo apiVersion) { - //API Version - if (!apiVersion.Version.IsNullOrEmpty()) - { - //TODO: What about other media types? - requestMessage.Headers.Add("accept", $"{MimeTypes.Text.Plain}; v={apiVersion.Version}"); - requestMessage.Headers.Add("accept", $"{MimeTypes.Application.Json}; v={apiVersion.Version}"); - requestMessage.Headers.Add("api-version", apiVersion.Version); - } + AddAcceptHeaders(action, requestMessage, apiVersion); //Header parameters var headers = action.Parameters.Where(p => p.BindingSourceId == ParameterBindingSources.Header).ToArray(); @@ -378,6 +404,57 @@ protected virtual void AddHeaders( } } + protected virtual void AddAcceptHeaders( + ActionApiDescriptionModel action, + HttpRequestMessage requestMessage, + ApiVersionInfo apiVersion) + { + var acceptForReturn = GetAcceptForActionReturn(action); + var versionSuffix = apiVersion.Version.IsNullOrEmpty() ? string.Empty : $"; v={apiVersion.Version}"; + + if (!acceptForReturn.IsNullOrEmpty()) + { + requestMessage.Headers.Add("accept", acceptForReturn + versionSuffix); + } + else + { + requestMessage.Headers.Add("accept", MimeTypes.Text.Plain + versionSuffix); + requestMessage.Headers.Add("accept", MimeTypes.Application.Json + versionSuffix); + } + + if (!apiVersion.Version.IsNullOrEmpty()) + { + requestMessage.Headers.Add("api-version", apiVersion.Version); + } + } + + protected virtual string? GetAcceptForActionReturn(ActionApiDescriptionModel action) + { + if (action.ReturnValue.IsRemoteStream || + action.ReturnValue.Type == typeof(IRemoteStreamContent).FullName || + action.ReturnValue.Type == typeof(RemoteStreamContent).FullName) + { + return MimeTypes.Application.OctetStream; + } + + var contentTypes = action.ReturnValue.ContentTypes; + if (contentTypes == null || contentTypes.Count == 0) + { + return null; + } + + var normalized = contentTypes.Select(NormalizeMediaType).ToList(); + + return normalized.FirstOrDefault(IsJsonMediaType) ?? normalized[0]; + } + + private static bool IsJsonMediaType(string normalizedMediaType) + { + return normalizedMediaType.Equals(MimeTypes.Application.Json, StringComparison.OrdinalIgnoreCase) || + normalizedMediaType.Equals("text/json", StringComparison.OrdinalIgnoreCase) || + normalizedMediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase); + } + protected virtual StringSegment RemoveQuotes(StringSegment input) { if (!StringSegment.IsNullOrEmpty(input) && input.Length >= 2 && input[0] == '"' && input[input.Length - 1] == '"') diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs index 7650e40f88e..e01b39c94da 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs @@ -45,7 +45,7 @@ public ActionApiDescriptionModel() } - public static ActionApiDescriptionModel Create([NotNull] string uniqueName, [NotNull] MethodInfo method, [NotNull] string url, string? httpMethod, [NotNull] IList supportedVersions, bool? allowAnonymous = null, IList? authorizeDatas = null, string? implementFrom = null) + public static ActionApiDescriptionModel Create([NotNull] string uniqueName, [NotNull] MethodInfo method, [NotNull] string url, string? httpMethod, [NotNull] IList supportedVersions, bool? allowAnonymous = null, IList? authorizeDatas = null, string? implementFrom = null, IList? returnValueContentTypes = null) { Check.NotNull(uniqueName, nameof(uniqueName)); Check.NotNull(method, nameof(method)); @@ -58,7 +58,7 @@ public static ActionApiDescriptionModel Create([NotNull] string uniqueName, [Not Name = method.Name, Url = url, HttpMethod = httpMethod, - ReturnValue = ReturnValueApiDescriptionModel.Create(method.ReturnType), + ReturnValue = ReturnValueApiDescriptionModel.Create(method.ReturnType, returnValueContentTypes), Parameters = new List(), ParametersOnMethod = method .GetParameters() diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs index e77d2f7fea6..25b2fb149af 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using Volo.Abp.Content; using Volo.Abp.Reflection; using Volo.Abp.Threading; @@ -13,19 +15,30 @@ public class ReturnValueApiDescriptionModel public string? Summary { get; set; } + public IList? ContentTypes { get; set; } + + public bool IsRemoteStream { get; set; } + public ReturnValueApiDescriptionModel() { } - public static ReturnValueApiDescriptionModel Create(Type type) + public static ReturnValueApiDescriptionModel Create(Type type, IList? contentTypes = null) { var unwrappedType = AsyncHelper.UnwrapTask(type); return new ReturnValueApiDescriptionModel { Type = TypeHelper.GetFullNameHandlingNullableAndGenerics(unwrappedType), - TypeSimple = ApiTypeNameHelper.GetSimpleTypeName(unwrappedType) + TypeSimple = ApiTypeNameHelper.GetSimpleTypeName(unwrappedType), + ContentTypes = contentTypes, + IsRemoteStream = IsRemoteStreamType(unwrappedType) }; } + + private static bool IsRemoteStreamType(Type type) + { + return type == typeof(IRemoteStreamContent) || type == typeof(RemoteStreamContent); + } } diff --git a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs index 18af3e06615..8e15d851916 100644 --- a/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs +++ b/framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs @@ -135,10 +135,9 @@ private static void AddActionScript(StringBuilder script, string controllerName, AddAjaxCallParameters(script, action); - var ajaxParamsIsFromForm = action.Parameters.Any(x => x.BindingSourceId == ParameterBindingSources.Form); - var dataType = action.ReturnValue.Type == ReturnValueApiDescriptionModel.Create(typeof(string)).Type - ? "{ dataType: 'text' }, " - : string.Empty; + var hasFormFile = action.Parameters.Any(x => x.BindingSourceId == ParameterBindingSources.FormFile); + var ajaxParamsIsFromForm = !hasFormFile && action.Parameters.Any(x => x.BindingSourceId == ParameterBindingSources.Form); + var dataType = GetJQueryDataTypeAndAcceptOverride(action); script.AppendLine(ajaxParamsIsFromForm ? " }, $.extend(true, {}, " + dataType + "{ contentType: 'application/x-www-form-urlencoded; charset=UTF-8' }, ajaxParams)));" : " }, " + dataType + "ajaxParams));"); @@ -146,6 +145,54 @@ private static void AddActionScript(StringBuilder script, string controllerName, script.AppendLine(" };"); } + private static string GetJQueryDataTypeAndAcceptOverride(ActionApiDescriptionModel action) + { + if (action.ReturnValue.IsRemoteStream) + { + return string.Empty; + } + + var contentTypes = action.ReturnValue.ContentTypes; + var isStringReturn = action.ReturnValue.Type == ReturnValueApiDescriptionModel.Create(typeof(string)).Type; + + if (contentTypes is { Count: > 0 }) + { + var normalized = contentTypes.Select(NormalizeMediaType).ToList(); + + var firstJsonShaped = normalized.FirstOrDefault(IsJsonMediaType); + if (firstJsonShaped != null) + { + return "{ dataType: 'json', headers: { Accept: '" + firstJsonShaped + "' } }, "; + } + + if (normalized.All(ct => ct.StartsWith("text/", StringComparison.OrdinalIgnoreCase))) + { + return "{ dataType: 'text', headers: { Accept: '" + normalized[0] + "' } }, "; + } + + return "{ headers: { Accept: '" + normalized[0] + "' } }, "; + } + + return isStringReturn ? "{ dataType: 'text' }, " : string.Empty; + } + + private static bool IsJsonMediaType(string normalizedMediaType) + { + return normalizedMediaType.Equals("application/json", StringComparison.OrdinalIgnoreCase) || + normalizedMediaType.Equals("text/json", StringComparison.OrdinalIgnoreCase) || + normalizedMediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeMediaType(string mediaType) + { + if (string.IsNullOrWhiteSpace(mediaType)) + { + return string.Empty; + } + var semi = mediaType.IndexOf(';'); + return (semi < 0 ? mediaType : mediaType.Substring(0, semi)).Trim().ToLowerInvariant(); + } + private static string FindBestApiVersion(ActionApiDescriptionModel action) { //var configuredVersion = GetConfiguredApiVersion(); //TODO: Implement @@ -184,6 +231,20 @@ private static void AddAjaxCallParameters(StringBuilder script, ActionApiDescrip script.Append(" headers: " + headers); } + var firstFileParam = action.Parameters.FirstOrDefault(p => p.BindingSourceId == ParameterBindingSources.FormFile); + if (firstFileParam != null) + { + var fileVar = ProxyScriptingJsFuncHelper.NormalizeJsVariableName(firstFileParam.NameOnMethod.ToCamelCase()); + script.AppendLine(","); + script.Append(" data: " + fileVar + ","); + script.AppendLine(); + script.Append(" processData: false,"); + script.AppendLine(); + script.Append(" contentType: false"); + script.AppendLine(); + return; + } + var body = ProxyScriptingHelper.GenerateBody(action); if (!body.IsNullOrEmpty()) { diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase_ContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase_ContentTypes_Tests.cs new file mode 100644 index 00000000000..c30fbb0d43c --- /dev/null +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase_ContentTypes_Tests.cs @@ -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? contentTypes) + { + return new ActionApiDescriptionModel + { + UniqueName = "Sample", + Name = "Sample", + HttpMethod = "GET", + Url = "api/test", + SupportedVersions = new List(), + ParametersOnMethod = new List(), + Parameters = new List(), + ReturnValue = new ReturnValueApiDescriptionModel + { + Type = returnType, + TypeSimple = returnType, + ContentTypes = contentTypes + }, + AuthorizeDatas = new List() + }; + } + + [Fact] + public void AddHeaders_With_ApiVersion_Should_Combine_OctetStream_Accept_With_Version_Suffix() + { + var action = BuildAction( + returnType: typeof(IRemoteStreamContent).FullName!, + contentTypes: new[] { "application/json", "text/plain" }); + + var headers = InvokeAddHeadersAndCollectAccept(action, version: "2.0"); + + headers.ShouldContain("application/octet-stream; v=2.0"); + headers.ShouldNotContain(h => h == "text/plain; v=2.0"); + headers.ShouldNotContain(h => h == "application/json; v=2.0"); + } + + [Fact] + public void AddHeaders_Without_ApiVersion_Should_Emit_OctetStream_For_Stream_Returns() + { + var action = BuildAction( + returnType: typeof(IRemoteStreamContent).FullName!, + contentTypes: new[] { "application/json" }); + + var headers = InvokeAddHeadersAndCollectAccept(action, version: null); + + headers.ShouldContain("application/octet-stream"); + } + + [Fact] + public void AddHeaders_Without_ContentType_Metadata_Should_Fall_Back_To_Text_And_Json_Pair() + { + var action = BuildAction(returnType: "System.Int32", contentTypes: null); + + var headers = InvokeAddHeadersAndCollectAccept(action, version: "1.0"); + + headers.ShouldContain("text/plain; v=1.0"); + headers.ShouldContain("application/json; v=1.0"); + } + + [Fact] + public void AddHeaders_Without_ApiVersion_And_Without_ContentType_Metadata_Should_Emit_Unversioned_Text_Json_Pair() + { + var action = BuildAction(returnType: "System.Int32", contentTypes: null); + + var headers = InvokeAddHeadersAndCollectAccept(action, version: null); + + headers.ShouldContain("text/plain"); + headers.ShouldContain("application/json"); + headers.ShouldNotContain(h => h.Contains("; v=")); + } + + private static IList InvokeAddHeadersAndCollectAccept(ActionApiDescriptionModel action, string? version) + { + var proxy = new TestableClientProxy(); + var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/x"); + var apiVersion = new ApiVersionInfo("HeaderModelBinding", version ?? string.Empty); + proxy.PublicAddAcceptHeaders(action, message, apiVersion); + return message.Headers.Accept.Select(a => a.ToString()).ToList(); + } + + private sealed class TestableClientProxy : ClientProxyBase + { + public string? PublicGetAcceptForActionReturn(ActionApiDescriptionModel action) + => GetAcceptForActionReturn(action); + + public void PublicAddAcceptHeaders(ActionApiDescriptionModel action, HttpRequestMessage requestMessage, ApiVersionInfo apiVersion) + => AddAcceptHeaders(action, requestMessage, apiVersion); + } +} diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyRequestPayloadBuilder_FormData_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyRequestPayloadBuilder_FormData_Tests.cs new file mode 100644 index 00000000000..12b453f4f42 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyRequestPayloadBuilder_FormData_Tests.cs @@ -0,0 +1,804 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Content; +using Volo.Abp.Http.Modeling; +using Volo.Abp.Http.ProxyScripting.Generators; +using Volo.Abp.Json; +using Volo.Abp.Timing; +using Xunit; +using MicrosoftOptions = Microsoft.Extensions.Options.Options; + +namespace Volo.Abp.Http.Client.ClientProxying; + +public class ClientProxyRequestPayloadBuilder_FormData_Tests +{ + private readonly ClientProxyRequestPayloadBuilder _builder; + private readonly IJsonSerializer _jsonSerializer = new StubJsonSerializer(); + private static readonly ApiVersionInfo NoApiVersion = new("Query", "1.0"); + + public ClientProxyRequestPayloadBuilder_FormData_Tests() + { + var services = new ServiceCollection(); + var scopeFactory = services.BuildServiceProvider().GetRequiredService(); + var options = MicrosoftOptions.Create(new AbpHttpClientProxyingOptions()); + _builder = new ClientProxyRequestPayloadBuilder(scopeFactory, options, new TestClock()); + } + + [Fact] + public async Task Direct_IRemoteStreamContent_Param_Should_Produce_Single_StreamContent_Part() + { + var action = BuildAction(parameters: new[] + { + FormFileParam(name: "file", nameOnMethod: "file"), + }); + var stream = MakeStream("hello-direct"); + var args = new Dictionary + { + ["file"] = new RemoteStreamContent(stream, "demo.txt", "text/plain"), + }; + + var content = await InvokeAsync(action, args); + + var multipart = content.ShouldBeOfType(); + var parts = multipart.ToList(); + parts.Count.ShouldBe(1); + parts[0].Headers.ContentType!.MediaType.ShouldBe("text/plain"); + (await parts[0].ReadAsStringAsync()).ShouldBe("hello-direct"); + } + + [Fact] + public async Task Dto_With_IRemoteStreamContent_Property_Should_Flatten_To_Name_Plus_File_Parts() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestUploadDto + { + Name = "Alice", + File = new RemoteStreamContent(MakeStream("hello-single"), "single.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var multipart = content.ShouldBeOfType(); + var parts = multipart.ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Name", "Alice"); + await AssertStreamPart(parts[1], "File", "hello-single", "text/plain"); + } + + [Fact] + public async Task Dto_With_IEnumerable_IRemoteStreamContent_Should_Emit_One_Part_Per_Stream() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Label", nameOnMethod: "input"), + FormFileParam(name: "Files", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestUploadFilesDto + { + Label = "batch", + Files = new[] + { + new RemoteStreamContent(MakeStream("a-content"), "a.txt", "text/plain"), + new RemoteStreamContent(MakeStream("b-content"), "b.txt", "text/csv"), + }, + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(3); + await AssertStringPart(parts[0], "Label", "batch"); + await AssertStreamPart(parts[1], "Files", "a-content", "text/plain"); + await AssertStreamPart(parts[2], "Files", "b-content", "text/csv"); + } + + [Fact] + public async Task Nested_Dto_With_Child_File_Path_Should_Be_Reflected_Via_Dotted_Name() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Outer", nameOnMethod: "input"), + FormFileParam(name: "Child.File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestNestedUploadDto + { + Outer = "outerVal", + Child = new TestNestedChildDto + { + File = new RemoteStreamContent(MakeStream("hello-nested"), "nested.txt", "text/plain"), + }, + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Outer", "outerVal"); + await AssertStreamPart(parts[1], "Child.File", "hello-nested", "text/plain"); + } + + [Fact] + public async Task Form_Only_Action_Without_FormFile_Should_Still_Produce_Multipart_With_String_Parts() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormParam(name: "Tag", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestUploadDto { Name = "Alice", Tag = "T1" }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Name", "Alice"); + await AssertStringPart(parts[1], "Tag", "T1"); + } + + [Fact] + public async Task Body_Binding_Wins_Over_Form_And_Returns_Json_StringContent() + { + var action = BuildAction(parameters: new[] + { + new ParameterApiDescriptionModel + { + Name = "input", + NameOnMethod = "input", + Type = typeof(TestUploadDto).FullName!, + TypeSimple = "dto", + BindingSourceId = ParameterBindingSources.Body, + }, + }); + var args = new Dictionary + { + ["input"] = new TestUploadDto { Name = "Alice" }, + }; + + var content = await InvokeAsync(action, args); + + var stringContent = content.ShouldBeOfType(); + stringContent.Headers.ContentType!.MediaType.ShouldBe("application/json"); + var body = await stringContent.ReadAsStringAsync(); + body.ShouldContain("\"Name\":\"Alice\""); + } + + [Fact] + public async Task No_Form_Or_Body_Params_Should_Return_Null_Content() + { + var action = BuildAction(parameters: new[] + { + new ParameterApiDescriptionModel + { + Name = "id", + NameOnMethod = "id", + Type = "System.Int32", + TypeSimple = "int", + BindingSourceId = ParameterBindingSources.Path, + }, + }); + var args = new Dictionary { ["id"] = 42 }; + + var content = await InvokeAsync(action, args); + + content.ShouldBeNull(); + } + + [Fact] + public async Task FormFile_With_Null_Value_Should_Be_Skipped_Not_Throw() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestUploadDto { Name = "Alice", File = null }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(1); + await AssertStringPart(parts[0], "Name", "Alice"); + } + + [Fact] + public async Task Three_Level_Nested_Dto_Should_Resolve_Outer_Inner_File_Via_Dotted_Path() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Outer", nameOnMethod: "input"), + FormFileParam(name: "Inner.Child.File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestThreeLevelDto + { + Outer = "outerVal", + Inner = new TestThreeLevelMiddleDto + { + Child = new TestNestedChildDto + { + File = new RemoteStreamContent(MakeStream("hello-3-levels"), "deep.txt", "text/plain"), + }, + }, + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Outer", "outerVal"); + await AssertStreamPart(parts[1], "Inner.Child.File", "hello-3-levels", "text/plain"); + } + + [Fact] + public async Task File_With_UTF8_FileName_Should_Survive_To_Content_Disposition_FileName_Star() + { + var action = BuildAction(parameters: new[] + { + FormFileParam(name: "file", nameOnMethod: "file"), + }); + var args = new Dictionary + { + ["file"] = new RemoteStreamContent(MakeStream("hello-utf8"), "中文-文件名.txt", "text/plain"), + }; + + var content = await InvokeAsync(action, args); + + var part = content.ShouldBeOfType().Single(); + var disposition = part.Headers.ContentDisposition; + disposition.ShouldNotBeNull(); + disposition!.FileNameStar.ShouldBe("中文-文件名.txt"); + (await part.ReadAsStringAsync()).ShouldBe("hello-utf8"); + } + + [Fact] + public async Task Dto_Treated_As_Body_When_Not_Registered_Should_Serialize_As_Json_With_File_Field_Embedded() + { + var action = BuildAction(parameters: new[] + { + new ParameterApiDescriptionModel + { + Name = "input", + NameOnMethod = "input", + Type = typeof(TestUploadDto).FullName!, + TypeSimple = "dto", + BindingSourceId = ParameterBindingSources.Body, + }, + }); + var args = new Dictionary + { + ["input"] = new TestUploadDto + { + Name = "Alice", + File = new RemoteStreamContent(MakeStream("ignored"), "x.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var stringContent = content.ShouldBeOfType(); + stringContent.Headers.ContentType!.MediaType.ShouldBe("application/json"); + var body = await stringContent.ReadAsStringAsync(); + body.ShouldContain("\"Name\":\"Alice\""); + body.ShouldContain("\"File\""); + } + + [Fact] + public async Task Dto_With_Both_Stream_Property_And_Stream_Collection_Should_Emit_All_Parts() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Label", nameOnMethod: "input"), + FormFileParam(name: "Main", nameOnMethod: "input"), + FormFileParam(name: "Extras", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestMixedUploadDto + { + Label = "combo", + Main = new RemoteStreamContent(MakeStream("main-body"), "main.txt", "text/plain"), + Extras = new[] + { + new RemoteStreamContent(MakeStream("extra-a"), "ea.txt", "text/csv"), + new RemoteStreamContent(MakeStream("extra-b"), "eb.txt", "text/plain"), + }, + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(4); + await AssertStringPart(parts[0], "Label", "combo"); + await AssertStreamPart(parts[1], "Main", "main-body", "text/plain"); + await AssertStreamPart(parts[2], "Extras", "extra-a", "text/csv"); + await AssertStreamPart(parts[3], "Extras", "extra-b", "text/plain"); + } + + [Fact] + public async Task Two_Different_Upload_Actions_On_Same_Builder_Should_Not_Pollute_Each_Other() + { + var actionA = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var actionB = BuildAction(parameters: new[] + { + FormParam(name: "Label", nameOnMethod: "input"), + FormFileParam(name: "Files", nameOnMethod: "input"), + }); + + var argsA = new Dictionary + { + ["input"] = new TestUploadDto { Name = "A", File = new RemoteStreamContent(MakeStream("aa"), "a.txt", "text/plain") }, + }; + var argsB = new Dictionary + { + ["input"] = new TestUploadFilesDto + { + Label = "B", + Files = new[] { new RemoteStreamContent(MakeStream("bb"), "b.txt", "text/csv") }, + }, + }; + + var contentA = (await InvokeAsync(actionA, argsA)).ShouldBeOfType().ToList(); + var contentB = (await InvokeAsync(actionB, argsB)).ShouldBeOfType().ToList(); + + await AssertStringPart(contentA[0], "Name", "A"); + await AssertStreamPart(contentA[1], "File", "aa", "text/plain"); + await AssertStringPart(contentB[0], "Label", "B"); + await AssertStreamPart(contentB[1], "Files", "bb", "text/csv"); + } + + [Fact] + public async Task Inherited_Dto_With_File_Property_On_Base_Class_Should_Resolve_Via_Reflection() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + FormParam(name: "ChildOnly", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestInheritedUploadDto + { + Name = "inherited", + File = new RemoteStreamContent(MakeStream("from-base"), "base.txt", "text/plain"), + ChildOnly = "extra", + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(3); + await AssertStringPart(parts[0], "Name", "inherited"); + await AssertStreamPart(parts[1], "File", "from-base", "text/plain"); + await AssertStringPart(parts[2], "ChildOnly", "extra"); + } + + [Fact] + public async Task Record_Dto_With_File_Property_Should_Resolve_Via_Reflection() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestRecordUploadDto( + Name: "Diana", + File: new RemoteStreamContent(MakeStream("from-record"), "r.txt", "text/plain")), + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Name", "Diana"); + await AssertStreamPart(parts[1], "File", "from-record", "text/plain"); + } + + [Fact] + public async Task Dto_With_DateTime_Enum_And_Nullable_Struct_Fields_Should_Round_Trip_As_String_Parts() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "When", nameOnMethod: "input"), + FormParam(name: "Status", nameOnMethod: "input"), + FormParam(name: "Quantity", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestPrimitiveUploadDto + { + When = new DateTime(2025, 6, 1, 12, 34, 56, DateTimeKind.Utc), + Status = TestStatus.Active, + Quantity = 7, + File = new RemoteStreamContent(MakeStream("primitives"), "p.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(4); + (await parts[0].ReadAsStringAsync()).ShouldStartWith("2025-06-01T12:34:56"); + (await parts[1].ReadAsStringAsync()).ShouldBe("Active"); + (await parts[2].ReadAsStringAsync()).ShouldBe("7"); + await AssertStreamPart(parts[3], "File", "primitives", "text/plain"); + } + + [Fact] + public async Task Dto_With_Nullable_Struct_Field_Set_To_Null_Should_Skip_The_Part_Entirely() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Quantity", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestPrimitiveUploadDto + { + Quantity = null, + File = new RemoteStreamContent(MakeStream("null-qty"), "n.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(1); + await AssertStreamPart(parts[0], "File", "null-qty", "text/plain"); + } + + [Fact] + public async Task Polymorphic_Dto_With_Derived_Type_Should_Reflect_Properties_From_Concrete_Runtime_Type() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Name", nameOnMethod: "input"), + FormParam(name: "ExtraField", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + TestUploadDtoBase polymorphic = new TestPolymorphicDerivedDto + { + Name = "poly", + ExtraField = "derived-only", + File = new RemoteStreamContent(MakeStream("from-derived"), "d.txt", "text/plain"), + }; + var args = new Dictionary { ["input"] = polymorphic }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(3); + await AssertStringPart(parts[0], "Name", "poly"); + await AssertStringPart(parts[1], "ExtraField", "derived-only"); + await AssertStreamPart(parts[2], "File", "from-derived", "text/plain"); + } + + [Fact] + public async Task Generic_Dto_Closed_Over_Concrete_Type_Should_Reflect_Open_Generic_Property() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Payload", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestGenericUploadDto + { + Payload = "closed-string", + File = new RemoteStreamContent(MakeStream("g.txt-body"), "g.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Payload", "closed-string"); + await AssertStreamPart(parts[1], "File", "g.txt-body", "text/plain"); + } + + [Fact] + public async Task Generic_Dto_With_Integer_Payload_Should_Convert_Via_ConvertValueToString() + { + var action = BuildAction(parameters: new[] + { + FormParam(name: "Payload", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["input"] = new TestGenericUploadDto + { + Payload = 42, + File = new RemoteStreamContent(MakeStream("int-payload"), "i.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Payload", "42"); + } + + [Fact] + public async Task Underlying_Caller_Stream_Is_Wrapped_Without_Buffering_So_Retry_Sees_Drained_Source() + { + // Pin pass-through behaviour: HttpClient retry that re-reads `sourceStream` + // observes an empty body (no internal buffering). Opting in to buffering + // later would double-allocate for large uploads. + var action = BuildAction(parameters: new[] + { + FormFileParam(name: "file", nameOnMethod: "file"), + }); + var sourceStream = MakeStream("retry-payload"); + var args = new Dictionary + { + ["file"] = new RemoteStreamContent(sourceStream, "r.txt", "text/plain"), + }; + + var content = await InvokeAsync(action, args); + + var part = content.ShouldBeOfType().Single(); + sourceStream.Position.ShouldBe(0); + + (await part.ReadAsStringAsync()).ShouldBe("retry-payload"); + + sourceStream.Position.ShouldBe(sourceStream.Length); + } + + [Fact] + public async Task Action_With_No_Parameters_Should_Return_Null_Content_Without_Throwing() + { + var action = BuildAction(parameters: Array.Empty()); + var args = new Dictionary(); + + var content = await InvokeAsync(action, args); + + content.ShouldBeNull(); + } + + [Fact] + public async Task Path_Plus_Form_Plus_FormFile_Should_Skip_Path_And_Emit_Multipart_Only() + { + var action = BuildAction(parameters: new[] + { + new ParameterApiDescriptionModel + { + Name = "id", + NameOnMethod = "id", + Type = "System.Int32", + TypeSimple = "int", + BindingSourceId = ParameterBindingSources.Path, + }, + FormParam(name: "Name", nameOnMethod: "input"), + FormFileParam(name: "File", nameOnMethod: "input"), + }); + var args = new Dictionary + { + ["id"] = 7, + ["input"] = new TestUploadDto + { + Name = "Bob", + File = new RemoteStreamContent(MakeStream("path-mixed"), "p.txt", "text/plain"), + }, + }; + + var content = await InvokeAsync(action, args); + + var parts = content.ShouldBeOfType().ToList(); + parts.Count.ShouldBe(2); + await AssertStringPart(parts[0], "Name", "Bob"); + await AssertStreamPart(parts[1], "File", "path-mixed", "text/plain"); + } + + private Task InvokeAsync(ActionApiDescriptionModel action, IReadOnlyDictionary args) + => _builder.BuildContentAsync(action, args, _jsonSerializer, NoApiVersion); + + private static ActionApiDescriptionModel BuildAction(ParameterApiDescriptionModel[] parameters) + => new() + { + UniqueName = "TestAction", + Name = "TestAction", + HttpMethod = "POST", + Url = "api/test", + SupportedVersions = new List(), + ParametersOnMethod = new List(), + Parameters = parameters.ToList(), + ReturnValue = new ReturnValueApiDescriptionModel + { + Type = "System.String", + TypeSimple = "string", + }, + AuthorizeDatas = new List(), + }; + + private static ParameterApiDescriptionModel FormParam(string name, string nameOnMethod) + => new() + { + Name = name, + NameOnMethod = nameOnMethod, + Type = "System.String", + TypeSimple = "string", + BindingSourceId = ParameterBindingSources.Form, + }; + + private static ParameterApiDescriptionModel FormFileParam(string name, string nameOnMethod) + => new() + { + Name = name, + NameOnMethod = nameOnMethod, + Type = typeof(IRemoteStreamContent).FullName!, + TypeSimple = "stream", + BindingSourceId = ParameterBindingSources.FormFile, + }; + + private static MemoryStream MakeStream(string text) + { + var ms = new MemoryStream(); + ms.Write(Encoding.UTF8.GetBytes(text)); + ms.Position = 0; + return ms; + } + + private static async Task AssertStringPart(HttpContent part, string expectedName, string expectedValue) + { + var disposition = part.Headers.ContentDisposition; + disposition.ShouldNotBeNull(); + disposition!.Name!.Trim('"').ShouldBe(expectedName); + (await part.ReadAsStringAsync()).ShouldBe(expectedValue); + } + + private static async Task AssertStreamPart(HttpContent part, string expectedName, string expectedBody, string expectedContentType) + { + var disposition = part.Headers.ContentDisposition; + disposition.ShouldNotBeNull(); + disposition!.Name!.Trim('"').ShouldBe(expectedName); + part.Headers.ContentType!.MediaType.ShouldBe(expectedContentType); + (await part.ReadAsStringAsync()).ShouldBe(expectedBody); + } + + private class TestUploadDto + { + public string? Name { get; set; } + public string? Tag { get; set; } + public IRemoteStreamContent? File { get; set; } + } + + private class TestUploadFilesDto + { + public string? Label { get; set; } + public IEnumerable? Files { get; set; } + } + + private class TestNestedUploadDto + { + public string? Outer { get; set; } + public TestNestedChildDto? Child { get; set; } + } + + private class TestNestedChildDto + { + public IRemoteStreamContent? File { get; set; } + } + + private class TestThreeLevelDto + { + public string? Outer { get; set; } + public TestThreeLevelMiddleDto? Inner { get; set; } + } + + private class TestThreeLevelMiddleDto + { + public TestNestedChildDto? Child { get; set; } + } + + private class TestMixedUploadDto + { + public string? Label { get; set; } + public IRemoteStreamContent? Main { get; set; } + public IEnumerable? Extras { get; set; } + } + + private class TestUploadDtoBase + { + public string? Name { get; set; } + public IRemoteStreamContent? File { get; set; } + } + + private class TestInheritedUploadDto : TestUploadDtoBase + { + public string? ChildOnly { get; set; } + } + + private record TestRecordUploadDto(string Name, IRemoteStreamContent File); + + private class TestPrimitiveUploadDto + { + public DateTime When { get; set; } + public TestStatus Status { get; set; } + public int? Quantity { get; set; } + public IRemoteStreamContent? File { get; set; } + } + + private enum TestStatus + { + Pending = 0, + Active = 1, + Done = 2, + } + + private class TestPolymorphicDerivedDto : TestUploadDtoBase + { + public string? ExtraField { get; set; } + } + + private class TestGenericUploadDto + { + public T? Payload { get; set; } + public IRemoteStreamContent? File { get; set; } + } + + private class StubJsonSerializer : IJsonSerializer + { + public string Serialize(object obj, bool camelCase = true, bool indented = false) + => System.Text.Json.JsonSerializer.Serialize(obj); + + public T Deserialize(string jsonString, bool camelCase = true) + => System.Text.Json.JsonSerializer.Deserialize(jsonString)!; + + public object Deserialize(Type type, string jsonString, bool camelCase = true) + => System.Text.Json.JsonSerializer.Deserialize(jsonString, type)!; + } + + private class TestClock : IClock + { + public DateTime Now => DateTime.UtcNow; + public DateTimeKind Kind => DateTimeKind.Utc; + public bool SupportsMultipleTimezone => false; + public DateTime Normalize(DateTime dateTime) => dateTime; + public DateTime ConvertToUserTime(DateTime utcDateTime) => utcDateTime; + public DateTimeOffset ConvertToUserTime(DateTimeOffset dateTimeOffset) => dateTimeOffset; + public DateTime ConvertToUtc(DateTime dateTime) => dateTime; + } +} diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs index 21786155bf8..fdf279e1ce4 100644 --- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs @@ -1,6 +1,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Volo.Abp.Content; namespace Volo.Abp.Http.DynamicProxying; @@ -8,6 +9,26 @@ public interface IRegularTestController { Task IncrementValueAsync(int value); + Task GetPlainStringAsync(); + + Task GetProducesJsonStringAsync(); + + Task GetProducesTextStringAsync(); + + Task GetNullStringAsync(); + + Task GetProducesJsonNullStringAsync(); + + Task GetEmptyStringAsync(); + + Task GetEscapedStringAsync(); + + Task DownloadIconAsync(); + + Task GetReferenceTypeObjectAsync(); + + Task GetByteArrayAsync(); + Task GetException1Async(); Task GetException2Async(); diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/PeopleAppServiceClientProxy_ReturnContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/PeopleAppServiceClientProxy_ReturnContentTypes_Tests.cs new file mode 100644 index 00000000000..b89692eb905 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/PeopleAppServiceClientProxy_ReturnContentTypes_Tests.cs @@ -0,0 +1,84 @@ +using System.IO; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Volo.Abp.Http.Client; +using Volo.Abp.TestApp.Application; +using Xunit; + +namespace Volo.Abp.Http.DynamicProxying; + +public class PeopleAppServiceClientProxy_ReturnContentTypes_Tests : AbpHttpClientTestBase +{ + private readonly IPeopleAppService _peopleAppService; + + public PeopleAppServiceClientProxy_ReturnContentTypes_Tests() + { + _peopleAppService = ServiceProvider.GetRequiredService(); + } + + [Fact] + public async Task EchoStatusAsync_Should_Return_Plain_String_Without_Quotes() + { + var status = await _peopleAppService.EchoStatusAsync(); + status.ShouldBe("Open"); + status.StartsWith("\"").ShouldBeFalse(); + status.EndsWith("\"").ShouldBeFalse(); + } + + [Fact] + public async Task EchoStatusWithProducesJsonAsync_Should_Return_Plain_String_Without_Quotes() + { + var status = await _peopleAppService.EchoStatusWithProducesJsonAsync(); + status.ShouldBe("Open"); + status.StartsWith("\"").ShouldBeFalse(); + status.EndsWith("\"").ShouldBeFalse(); + } + + [Fact] + public async Task GetBinaryImageAsync_Should_Return_Real_Binary_Not_Json_Metadata() + { + using var content = await _peopleAppService.GetBinaryImageAsync(); + using var ms = new MemoryStream(); + await content.GetStream().CopyToAsync(ms); + var bytes = ms.ToArray(); + + content.FileName.ShouldBe("tiny.png"); + content.ContentType.ShouldStartWith("image/png"); + bytes.Length.ShouldBeGreaterThan(8); + + bytes[0].ShouldBe((byte)0x89); + bytes[1].ShouldBe((byte)0x50); + bytes[2].ShouldBe((byte)0x4E); + bytes[3].ShouldBe((byte)0x47); + } + + [Fact] + public async Task ThrowFromStringAsync_Should_Surface_Server_Exception_To_Client() + { + await Should.ThrowAsync( + () => _peopleAppService.ThrowFromStringAsync() + ); + } + + [Fact] + public async Task DownloadAsync_Should_Still_Work() + { + using var content = await _peopleAppService.DownloadAsync(); + using var reader = new StreamReader(content.GetStream()); + var text = await reader.ReadToEndAsync(); + text.ShouldBe("DownloadAsync"); + content.FileName.ShouldBe("download.rtf"); + content.ContentType.ShouldStartWith("application/rtf"); + } + + [Fact] + public async Task UploadAsync_String_Return_Should_Stay_Unquoted() + { + using var ms = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("hello")); + var result = await _peopleAppService.UploadAsync( + new Content.RemoteStreamContent(ms, "upload.txt", "text/plain")); + result.ShouldBe("hello:text/plain:upload.txt"); + result.StartsWith("\"").ShouldBeFalse(); + } +} diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs index 46b355090b5..1d0864991b0 100644 --- a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs @@ -1,10 +1,13 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Volo.Abp.AspNetCore.Mvc; +using Volo.Abp.Content; namespace Volo.Abp.Http.DynamicProxying; @@ -20,6 +23,82 @@ public Task IncrementValueAsync(int value) return Task.FromResult(value + 1); } + [HttpGet] + [Route("plain-string")] + public Task GetPlainStringAsync() + { + return Task.FromResult("Open"); + } + + [HttpGet] + [Route("produces-json-string")] + [Produces("application/json")] + public Task GetProducesJsonStringAsync() + { + return Task.FromResult("Open"); + } + + [HttpGet] + [Route("produces-text-string")] + [Produces("text/plain")] + public Task GetProducesTextStringAsync() + { + return Task.FromResult("Open"); + } + + [HttpGet] + [Route("null-string")] + public Task GetNullStringAsync() + { + return Task.FromResult(null!); + } + + [HttpGet] + [Route("produces-json-null-string")] + [Produces("application/json")] + public Task GetProducesJsonNullStringAsync() + { + return Task.FromResult(null!); + } + + [HttpGet] + [Route("empty-string")] + public Task GetEmptyStringAsync() + { + return Task.FromResult(string.Empty); + } + + [HttpGet] + [Route("escaped-string")] + [Produces("application/json")] + public Task GetEscapedStringAsync() + { + return Task.FromResult("a\"b\\c\nd"); + } + + [HttpGet] + [Route("download-icon")] + public Task DownloadIconAsync() + { + var bytes = Encoding.UTF8.GetBytes("ICON-BYTES"); + return Task.FromResult( + new RemoteStreamContent(new MemoryStream(bytes), "icon.bin", "application/octet-stream")); + } + + [HttpGet] + [Route("reference-type-object")] + public Task GetReferenceTypeObjectAsync() + { + return Task.FromResult(new Car { Year = 1999, Model = "BMW" }); + } + + [HttpGet] + [Route("byte-array")] + public Task GetByteArrayAsync() + { + return Task.FromResult(new byte[] { 1, 2, 3, 4 }); + } + [HttpGet] [Route("get-exception1")] public Task GetException1Async() diff --git a/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_ReturnContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_ReturnContentTypes_Tests.cs new file mode 100644 index 00000000000..92c8b5b23cb --- /dev/null +++ b/framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_ReturnContentTypes_Tests.cs @@ -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(); + } + + [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(); + } + + [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); + } +} diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel_Tests.cs new file mode 100644 index 00000000000..d3c31979374 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel_Tests.cs @@ -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)); + model.IsRemoteStream.ShouldBeTrue(); + } + + [Fact] + public void Task_Of_Custom_Stream_Subclass_Should_Be_False_After_UnwrapTask() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(Task)); + 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)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void IEnumerable_Of_IRemoteStreamContent_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(IEnumerable)); + model.IsRemoteStream.ShouldBeFalse(); + } + + [Fact] + public void IReadOnlyCollection_Of_IRemoteStreamContent_Should_Be_False() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(IReadOnlyCollection)); + 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)); + 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( + json, + new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + model.ShouldNotBeNull(); + model!.Type.ShouldBe("System.String"); + model.TypeSimple.ShouldBe("string"); + model.ContentTypes.ShouldBeNull(); + } + + [Fact] + public void Deserializing_Json_With_ContentTypes_Field_Should_Populate_It() + { + var json = """ + { + "type": "System.String", + "typeSimple": "string", + "contentTypes": ["application/json", "text/plain"] + } + """; + + var model = System.Text.Json.JsonSerializer.Deserialize( + json, + new System.Text.Json.JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + model!.ContentTypes.ShouldNotBeNull(); + model.ContentTypes!.ShouldBe(new[] { "application/json", "text/plain" }); + } + + [Fact] + public void Serializing_With_Null_ContentTypes_Should_Emit_Null_Or_Omit() + { + var model = ReturnValueApiDescriptionModel.Create(typeof(string)); + var json = System.Text.Json.JsonSerializer.Serialize(model); + + var deserialized = System.Text.Json.JsonSerializer.Deserialize( + json, + new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + deserialized!.ContentTypes.ShouldBeNull(); + } +} + +public class ActionApiDescriptionModel_Tests +{ + [Fact] + public void Create_Should_Propagate_ReturnValueContentTypes() + { + var method = typeof(ActionApiDescriptionModel_Tests).GetMethod(nameof(SampleMethod))!; + var model = ActionApiDescriptionModel.Create( + uniqueName: "SampleMethod", + method: method, + url: "api/test/sample", + httpMethod: "GET", + supportedVersions: new[] { "1.0" }, + allowAnonymous: true, + authorizeDatas: null, + implementFrom: null, + returnValueContentTypes: new[] { "application/octet-stream" }); + + model.ReturnValue.ContentTypes.ShouldNotBeNull(); + model.ReturnValue.ContentTypes!.ShouldBe(new[] { "application/octet-stream" }); + } + + [Fact] + public void Create_Without_ReturnValueContentTypes_Should_Leave_Null() + { + var method = typeof(ActionApiDescriptionModel_Tests).GetMethod(nameof(SampleMethod))!; + var model = ActionApiDescriptionModel.Create( + uniqueName: "SampleMethod", + method: method, + url: "api/test/sample", + httpMethod: "GET", + supportedVersions: new[] { "1.0" }); + + model.ReturnValue.ContentTypes.ShouldBeNull(); + } + + public string SampleMethod() => string.Empty; +} diff --git a/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs new file mode 100644 index 00000000000..a7c63f82701 --- /dev/null +++ b/framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs @@ -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? 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(), + ParametersOnMethod = new List(), + Parameters = new List(), + ReturnValue = new ReturnValueApiDescriptionModel + { + Type = returnType, + TypeSimple = returnType, + ContentTypes = contentTypes, + IsRemoteStream = isRemoteStream, + }, + AuthorizeDatas = new List(), + }; + 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(); + foreach (var (name, nameOnMethod, binding) in uploadParameters) + { + parameters.Add(new ParameterApiDescriptionModel + { + Name = name, + NameOnMethod = nameOnMethod, + Type = "System.String", + TypeSimple = "string", + BindingSourceId = binding, + }); + } + + var action = new ActionApiDescriptionModel + { + UniqueName = "UploadAsync", + Name = "UploadAsync", + HttpMethod = "POST", + Url = "api/test/upload", + SupportedVersions = new List(), + ParametersOnMethod = new List(), + Parameters = parameters, + ReturnValue = new ReturnValueApiDescriptionModel + { + Type = "System.Void", + TypeSimple = "void", + }, + AuthorizeDatas = new List(), + }; + controller.AddAction("UploadAsync", action); + + return model; + } +} diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IPeopleAppService.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IPeopleAppService.cs index a39216add5c..24ad49d7fae 100644 --- a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IPeopleAppService.cs +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IPeopleAppService.cs @@ -39,4 +39,12 @@ public interface IPeopleAppService : ICrudAppService Task GetParamsFromQueryAsync(GetParamsInput input); Task GetParamsFromFormAsync(GetParamsInput input); + + Task EchoStatusAsync(); + + Task EchoStatusWithProducesJsonAsync(); + + Task GetBinaryImageAsync(); + + Task ThrowFromStringAsync(); } diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/PeopleAppService.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/PeopleAppService.cs index a3920ec8ca7..5eb93707544 100644 --- a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/PeopleAppService.cs +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/PeopleAppService.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Shouldly; +using Volo.Abp; using Volo.Abp.Application.Dtos; using Volo.Abp.TestApp.Domain; using Volo.Abp.Domain.Repositories; @@ -94,6 +95,30 @@ public async Task DownloadAsync() return new RemoteStreamContent(memoryStream, "download.rtf", "application/rtf"); } + public Task EchoStatusAsync() + { + return Task.FromResult("Open"); + } + + [Produces("application/json")] + public Task EchoStatusWithProducesJsonAsync() + { + return Task.FromResult("Open"); + } + + public Task GetBinaryImageAsync() + { + var bytes = Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="); + return Task.FromResult( + new RemoteStreamContent(new MemoryStream(bytes), "tiny.png", "image/png")); + } + + public Task ThrowFromStringAsync() + { + throw new BusinessException("TestApp.StringEndpointBoom", "string endpoint failed"); + } + public async Task UploadAsync(IRemoteStreamContent streamContent) { using (var reader = new StreamReader(streamContent.GetStream())) diff --git a/npm/ng-packs/packages/core/src/lib/services/rest.service.ts b/npm/ng-packs/packages/core/src/lib/services/rest.service.ts index a2d45e2f120..4121eaa75aa 100644 --- a/npm/ng-packs/packages/core/src/lib/services/rest.service.ts +++ b/npm/ng-packs/packages/core/src/lib/services/rest.service.ts @@ -1,7 +1,7 @@ -import { HttpClient, HttpParameterCodec, HttpParams, HttpRequest } from '@angular/common/http'; +import { HttpClient, HttpHeaders, HttpParameterCodec, HttpParams, HttpRequest } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; -import { Observable, throwError } from 'rxjs'; -import { catchError } from 'rxjs/operators'; +import { Observable, from, of, throwError } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; import { ExternalHttpClient } from '../clients/http.client'; import { ABP } from '../models/common'; import { Rest } from '../models/rest'; @@ -39,7 +39,10 @@ export class RestService { api = api || this.getApiFromStore(config.apiName); const { method, params, ...options } = request; const { observe = Rest.Observe.Body, skipHandleError, responseType = Rest.ResponseType.JSON } = config; + const effectiveResponseType = + ((request as Rest.Request).responseType as Rest.ResponseType | undefined) ?? responseType; const url = this.removeDuplicateSlashes(api + request.url); + const headers = this.ensureAcceptHeader(options.headers, effectiveResponseType); const httpClient: HttpClient = this.getHttpClient(config.skipAddingHeader); return httpClient @@ -50,13 +53,111 @@ export class RestService { params: this.getParams(params, config.httpParamEncoder), }), ...options, + ...(headers && { headers }), } as any) - .pipe(catchError(err => (skipHandleError ? throwError(() => err) : this.handleError(err)))); + .pipe( + catchError(err => { + if (skipHandleError) { + return throwError(() => err); + } + return this.normalizeErrorBody(err, effectiveResponseType).pipe( + switchMap(normalizedErr => this.handleError(normalizedErr)), + ); + }), + ); } private getHttpClient(isExternal: boolean) { return isExternal ? this.externalHttp : this.http; } + private ensureAcceptHeader( + headers: Rest.Request['headers'], + responseType: Rest.ResponseType, + ): Rest.Request['headers'] | undefined { + const accept = this.getAcceptForResponseType(responseType); + if (!accept) return headers; + if (this.hasAcceptHeader(headers)) return headers; + if (headers instanceof HttpHeaders) { + return headers.set('Accept', accept); + } + return { ...(headers || {}), Accept: accept }; + } + + private hasAcceptHeader(headers: Rest.Request['headers']): boolean { + if (!headers) return false; + if (headers instanceof HttpHeaders) return headers.has('Accept'); + return Object.keys(headers).some(key => key.toLowerCase() === 'accept'); + } + + private getAcceptForResponseType(responseType: Rest.ResponseType): string | null { + switch (responseType) { + case Rest.ResponseType.Blob: + case Rest.ResponseType.ArrayBuffer: + return 'application/octet-stream'; + default: + return null; + } + } + + private normalizeErrorBody(err: any, responseType: Rest.ResponseType): Observable { + if (!err || responseType === Rest.ResponseType.JSON) { + return of(err); + } + + if (typeof err.error === 'string') { + this.tryParseJsonErrorText(err, err.error); + return of(err); + } + + if (typeof Blob !== 'undefined' && err.error instanceof Blob) { + return from(this.readBlobAsText(err.error)).pipe( + map((text: string) => { + this.tryParseJsonErrorText(err, text); + return err; + }), + catchError(() => of(err)), + ); + } + + return of(err); + } + + private readBlobAsText(blob: Blob): Promise { + if (typeof (blob as any).text === 'function') { + return (blob as any).text(); + } + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : ''); + reader.onerror = () => reject(reader.error); + reader.readAsText(blob); + }); + } + + private tryParseJsonErrorText(err: any, text: string): void { + if (!text || text.length === 0) return; + let parsed: any; + try { + parsed = JSON.parse(text); + } catch { + return; + } + if (this.looksLikeAbpErrorEnvelope(parsed)) { + err.error = parsed; + } + } + + private looksLikeAbpErrorEnvelope(parsed: any): boolean { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false; + const inner = parsed.error; + if (!inner || typeof inner !== 'object') return false; + return ( + typeof inner.code === 'string' || + typeof inner.message === 'string' || + Array.isArray(inner.validationErrors) + ); + } + private getParams(params: Rest.Params, encoder?: HttpParameterCodec): HttpParams { const filteredParams = Object.entries(params).reduce((acc, [key, value]) => { if (isUndefinedOrEmptyString(value)) return acc; diff --git a/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts index 9ac795c24f1..3009b0d6dcd 100644 --- a/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts +++ b/npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts @@ -1,3 +1,4 @@ +import { HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { createHttpFactory, HttpMethod, SpectatorHttp, SpyObject } from '@ngneat/spectator/vitest'; import { OAuthService } from 'angular-oauth2-oidc'; import { of, throwError } from 'rxjs'; @@ -130,6 +131,367 @@ describe('HttpClient testing', () => { spectator.flushAll([req], [throwError('Testing error')]); }); + test('should set Accept: application/octet-stream when config.responseType is blob', () => { + spectator.service + .request({ method: HttpMethod.GET, url: '/file' }, { responseType: Rest.ResponseType.Blob }) + .subscribe(); + const req = spectator.expectOne(api + '/file', HttpMethod.GET); + expect(req.request.headers.get('Accept')).toEqual('application/octet-stream'); + expect(req.request.responseType).toEqual('blob'); + }); + + test('should set Accept based on request-level responseType (generator path)', () => { + spectator.service + .request({ method: HttpMethod.GET, url: '/file', responseType: 'blob' }) + .subscribe(); + const req = spectator.expectOne(api + '/file', HttpMethod.GET); + expect(req.request.headers.get('Accept')).toEqual('application/octet-stream'); + expect(req.request.responseType).toEqual('blob'); + }); + + test('should set Accept: application/octet-stream when responseType is arraybuffer', () => { + spectator.service + .request( + { method: HttpMethod.GET, url: '/binary' }, + { responseType: Rest.ResponseType.ArrayBuffer }, + ) + .subscribe(); + const req = spectator.expectOne(api + '/binary', HttpMethod.GET); + expect(req.request.headers.get('Accept')).toEqual('application/octet-stream'); + }); + + test('should NOT add Accept for text responseType (left to schematic / caller)', () => { + spectator.service + .request({ method: HttpMethod.GET, url: '/text' }, { responseType: Rest.ResponseType.Text }) + .subscribe(); + const req = spectator.expectOne(api + '/text', HttpMethod.GET); + expect(req.request.headers.has('Accept')).toBe(false); + }); + + test('should not override caller-supplied Accept header (plain object)', () => { + spectator.service + .request( + { method: HttpMethod.GET, url: '/file', headers: { Accept: 'image/png' } }, + { responseType: Rest.ResponseType.Blob }, + ) + .subscribe(); + const req = spectator.expectOne(api + '/file', HttpMethod.GET); + expect(req.request.headers.get('Accept')).toEqual('image/png'); + }); + + test('should not override caller-supplied Accept header (HttpHeaders)', () => { + spectator.service + .request( + { + method: HttpMethod.GET, + url: '/file', + headers: new HttpHeaders({ Accept: 'image/jpeg' }), + }, + { responseType: Rest.ResponseType.Blob }, + ) + .subscribe(); + const req = spectator.expectOne(api + '/file', HttpMethod.GET); + expect(req.request.headers.get('Accept')).toEqual('image/jpeg'); + }); + + test('should preserve caller-supplied non-Accept headers and add Accept', () => { + spectator.service + .request( + { method: HttpMethod.GET, url: '/file', headers: { 'X-Custom': '1' } }, + { responseType: Rest.ResponseType.Blob }, + ) + .subscribe(); + const req = spectator.expectOne(api + '/file', HttpMethod.GET); + expect(req.request.headers.get('Accept')).toEqual('application/octet-stream'); + expect(req.request.headers.get('X-Custom')).toEqual('1'); + }); + + test('should not add Accept header for default JSON responseType', () => { + spectator.service.request({ method: HttpMethod.GET, url: '/json' }).subscribe(); + const req = spectator.expectOne(api + '/json', HttpMethod.GET); + expect(req.request.headers.has('Accept')).toBe(false); + }); + + test('should NOT unwrap error body when skipHandleError is true', async () => { + const spy = vi.spyOn(httpErrorReporter, 'reportError'); + + const completion = new Promise((resolve, reject) => { + spectator.service + .request( + { method: HttpMethod.GET, url: '/text' }, + { responseType: Rest.ResponseType.Text, skipHandleError: true }, + ) + .pipe( + catchError(err => { + try { + expect(spy).toHaveBeenCalledTimes(0); + expect(typeof err.error).toBe('string'); + expect(err.error).toBe('{"error":{"code":"X","message":"y"}}'); + resolve(); + } catch (e) { + reject(e); + } + return of(null); + }), + ) + .subscribe(); + }); + + const req = spectator.expectOne(api + '/text', HttpMethod.GET); + req.flush('{"error":{"code":"X","message":"y"}}', { + status: 500, + statusText: 'Internal Server Error', + }); + + await completion; + }); + + test('should unwrap ABP validationErrors envelope in text mode', async () => { + const spy = vi.spyOn(httpErrorReporter, 'reportError'); + + const completion = new Promise((resolve, reject) => { + spectator.service + .request({ method: HttpMethod.GET, url: '/text' }, { responseType: Rest.ResponseType.Text }) + .pipe( + catchError(() => { + try { + const errArg: any = spy.mock.calls[0][0]; + expect(typeof errArg.error).toBe('object'); + expect(errArg.error.error.message).toBe('Validation failed'); + expect(errArg.error.error.validationErrors).toHaveLength(1); + resolve(); + } catch (e) { + reject(e); + } + return of(null); + }), + ) + .subscribe(); + }); + + const req = spectator.expectOne(api + '/text', HttpMethod.GET); + req.flush( + '{"error":{"message":"Validation failed","validationErrors":[{"message":"Required","members":["Name"]}]}}', + { status: 400, statusText: 'Bad Request' }, + ); + + await completion; + }); + + test('should unwrap ABP envelope that carries only validationErrors (no message / code)', async () => { + const completion = new Promise((resolve, reject) => { + spectator.service + .request({ method: HttpMethod.GET, url: '/text' }, { responseType: Rest.ResponseType.Text }) + .pipe( + catchError(err => { + try { + expect(typeof err.error).toBe('object'); + expect(err.error.error.validationErrors).toHaveLength(1); + resolve(); + } catch (e) { + reject(e); + } + return of(null); + }), + ) + .subscribe(); + }); + + const req = spectator.expectOne(api + '/text', HttpMethod.GET); + req.flush( + '{"error":{"validationErrors":[{"message":"Required","members":["Email"]}]}}', + { status: 400, statusText: 'Bad Request' }, + ); + + await completion; + }); + + test('should leave non-ABP-envelope JSON body alone in text mode', async () => { + const completion = new Promise((resolve, reject) => { + spectator.service + .request({ method: HttpMethod.GET, url: '/text' }, { responseType: Rest.ResponseType.Text }) + .pipe( + catchError(err => { + try { + expect(typeof err.error).toBe('string'); + expect(err.error).toBe('{"foo":"bar"}'); + resolve(); + } catch (e) { + reject(e); + } + return of(null); + }), + ) + .subscribe(); + }); + + const req = spectator.expectOne(api + '/text', HttpMethod.GET); + req.flush('{"foo":"bar"}', { status: 500, statusText: 'err' }); + + await completion; + }); + + test('should unwrap JSON-encoded error body in text mode for HttpErrorReporter', async () => { + const spy = vi.spyOn(httpErrorReporter, 'reportError'); + + const completion = new Promise((resolve, reject) => { + spectator.service + .request( + { method: HttpMethod.GET, url: '/text' }, + { responseType: Rest.ResponseType.Text }, + ) + .pipe( + catchError(() => { + try { + expect(spy).toHaveBeenCalledTimes(1); + const errArg: any = spy.mock.calls[0][0]; + expect(errArg.error).toEqual({ + error: { code: 'AbpAuthorization.001', message: 'forbidden' }, + }); + resolve(); + } catch (e) { + reject(e); + } + return of(null); + }), + ) + .subscribe(); + }); + + const req = spectator.expectOne(api + '/text', HttpMethod.GET); + req.flush('{"error":{"code":"AbpAuthorization.001","message":"forbidden"}}', { + status: 403, + statusText: 'Forbidden', + }); + + await completion; + }); + + test('should unwrap ABP error envelope from a Blob body in blob mode', async () => { + // HttpTestingController doesn't deliver Blob in HttpErrorResponse; call the helper directly. + const err: any = { + error: new Blob( + ['{"error":{"code":"AbpAuthorization.002","message":"forbidden-blob"}}'], + { type: 'application/json' }, + ), + }; + + const normalized: any = await (spectator.service as any) + .normalizeErrorBody(err, Rest.ResponseType.Blob) + .toPromise(); + + expect(normalized.error).toEqual({ + error: { code: 'AbpAuthorization.002', message: 'forbidden-blob' }, + }); + }); + + test('should leave non-JSON Blob body alone in blob mode', async () => { + const blob = new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type: 'image/jpeg' }); + const err: any = { error: blob }; + + const normalized: any = await (spectator.service as any) + .normalizeErrorBody(err, Rest.ResponseType.Blob) + .toPromise(); + + expect(normalized.error).toBe(blob); + }); + + test('should swallow Blob.text() rejection and keep the original error in blob mode', async () => { + const fakeBlob = { + text: () => Promise.reject(new Error('boom')), + } as unknown as Blob; + const err: any = { error: fakeBlob, message: 'original' }; + + Object.setPrototypeOf(fakeBlob, Blob.prototype); + + const normalized: any = await (spectator.service as any) + .normalizeErrorBody(err, Rest.ResponseType.Blob) + .toPromise(); + + expect(normalized).toBe(err); + expect(normalized.error).toBe(fakeBlob); + }); + + test('should leave non-JSON error body alone in text mode', async () => { + const spy = vi.spyOn(httpErrorReporter, 'reportError'); + + const completion = new Promise((resolve, reject) => { + spectator.service + .request( + { method: HttpMethod.GET, url: '/text' }, + { responseType: Rest.ResponseType.Text }, + ) + .pipe( + catchError(() => { + try { + const errArg: any = spy.mock.calls[0][0]; + expect(errArg.error).toBe('plain error text'); + resolve(); + } catch (e) { + reject(e); + } + return of(null); + }), + ) + .subscribe(); + }); + + const req = spectator.expectOne(api + '/text', HttpMethod.GET); + req.flush('plain error text', { status: 500, statusText: 'Internal Server Error' }); + + await completion; + }); + + test('should send Accept header emitted by schematic verbatim (json scenario)', () => { + spectator.service + .request({ + method: HttpMethod.GET, + url: '/status', + headers: { Accept: 'application/json' }, + }) + .subscribe(); + const req = spectator.expectOne(api + '/status', HttpMethod.GET); + expect(req.request.headers.get('Accept')).toEqual('application/json'); + }); + + test('should send Accept header emitted by schematic verbatim (text scenario)', () => { + spectator.service + .request({ method: HttpMethod.GET, url: '/csv', headers: { Accept: 'text/plain' } }) + .subscribe(); + const req = spectator.expectOne(api + '/csv', HttpMethod.GET); + expect(req.request.headers.get('Accept')).toEqual('text/plain'); + }); + + test('should leave JSON error body alone in json mode (no double-parse)', async () => { + const spy = vi.spyOn(httpErrorReporter, 'reportError'); + + const completion = new Promise((resolve, reject) => { + spectator.service + .request({ method: HttpMethod.GET, url: '/json' }) + .pipe( + catchError(() => { + try { + const errArg: any = spy.mock.calls[0][0]; + expect(errArg.error).toEqual({ error: { code: 'X', message: 'y' } }); + resolve(); + } catch (e) { + reject(e); + } + return of(null); + }), + ) + .subscribe(); + }); + + const req = spectator.expectOne(api + '/json', HttpMethod.GET); + req.flush( + { error: { code: 'X', message: 'y' } }, + { status: 403, statusText: 'Forbidden' }, + ); + + await completion; + }); + test('should remove the duplicate slashes', () => { spectator.service .request({ method: HttpMethod.GET, url: '//test', params: { id: 1 } }) diff --git a/npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template b/npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template index c445286162a..22443a5a889 100644 --- a/npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template +++ b/npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template @@ -11,15 +11,17 @@ export class <%= name %>Service { <% const isBlob = body.isBlobMethod() ; const responseType = isBlob ? "Blob":body.responseType; + const httpResponseType = body.httpResponseType; + const acceptHeader = body.acceptHeader; %> <%= camel(signature.name) %> = (<%= serializeParameters(signature.parameters) %>) => this.restService.request<<%= body.requestType %>, <%= responseType %>>({ method: '<%= body.method %>',<% - if (body.responseType === 'string') { %> - responseType: 'text',<% } %><% - if (isBlob) { %> - responseType: 'blob',<% } %> + if (httpResponseType && httpResponseType !== 'json') { %> + responseType: '<%= httpResponseType %>',<% } %><% + if (acceptHeader) { %> + headers: { Accept: '<%= acceptHeader %>' },<% } %> url: <%= body.url %>,<% if (body.dictParamVar && !body.params.length) { %> params: <%= body.dictParamVar %>,<% } %><% diff --git a/npm/ng-packs/packages/schematics/src/enums/binding-source-id.ts b/npm/ng-packs/packages/schematics/src/enums/binding-source-id.ts index 308b8dfc326..91c1901e09f 100644 --- a/npm/ng-packs/packages/schematics/src/enums/binding-source-id.ts +++ b/npm/ng-packs/packages/schematics/src/enums/binding-source-id.ts @@ -3,5 +3,6 @@ export enum eBindingSourceId { Model = 'ModelBinding', Path = 'Path', Query = 'Query', + Form = 'Form', FormFile = 'FormFile', } diff --git a/npm/ng-packs/packages/schematics/src/models/api-definition.ts b/npm/ng-packs/packages/schematics/src/models/api-definition.ts index 70dca68dd5f..3e6458e91d4 100644 --- a/npm/ng-packs/packages/schematics/src/models/api-definition.ts +++ b/npm/ng-packs/packages/schematics/src/models/api-definition.ts @@ -68,7 +68,7 @@ export interface Action { supportedVersions: string[]; parametersOnMethod: ParameterInSignature[]; parameters: ParameterInBody[]; - returnValue: TypeDef; + returnValue: ReturnValueDef; } export interface ParameterInSignature { @@ -100,6 +100,11 @@ export interface TypeDef { typeSimple: string; } +export interface ReturnValueDef extends TypeDef { + contentTypes?: string[]; + isRemoteStream?: boolean; +} + export interface TypeWithEnum { isEnum: boolean; type: string; diff --git a/npm/ng-packs/packages/schematics/src/models/method.ts b/npm/ng-packs/packages/schematics/src/models/method.ts index 404fa9d2337..c160359770b 100644 --- a/npm/ng-packs/packages/schematics/src/models/method.ts +++ b/npm/ng-packs/packages/schematics/src/models/method.ts @@ -45,6 +45,8 @@ export class Body { responseTypeWithNamespace: string; requestType = 'any'; responseType: string; + httpResponseType?: 'json' | 'text' | 'blob' | 'arraybuffer'; + acceptHeader?: string; url: string; registerActionParameter = (param: ParameterInBody) => { @@ -85,6 +87,9 @@ export class Body { } isBlobMethod() { + if (this.httpResponseType === 'blob') { + return true; + } return VOLO_REMOTE_STREAM_CONTENT.some(x => x === this.responseTypeWithNamespace); } diff --git a/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts b/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts new file mode 100644 index 00000000000..9e35e9ef8c0 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts @@ -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 { + return { + uniqueName: 'GetStatusAsync', + name: 'GetStatus', + httpMethod: 'GET', + url: 'api/app/test-service/status', + supportedVersions: [], + parametersOnMethod: [], + parameters: [], + returnValue: { type: 'System.String', typeSimple: 'string' }, + ...overrides, + } as Action; +} + +describe('createActionToBodyMapper — string return value', () => { + const mapBody = createActionToBodyMapper(); + + test('without contentTypes falls back to text mode (legacy behavior)', () => { + const body = mapBody(buildAction({})); + + expect(body.responseType).toBe('string'); + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('with contentTypes containing application/json picks json + Accept: application/json', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['application/json', 'text/plain'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('with only text/* contentTypes picks text + Accept: text/plain', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['text/plain', 'text/csv'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBe('text/plain'); + }); +}); + +describe('createActionToBodyMapper — IRemoteStreamContent return value', () => { + const mapBody = createActionToBodyMapper(); + + test('always picks blob + Accept: application/octet-stream regardless of contentTypes', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.IRemoteStreamContent', + typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + expect(body.isBlobMethod()).toBe(true); + }); + + test('binary-only contentTypes 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), + ); + + 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), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('void return has no httpResponseType / acceptHeader', () => { + const body = mapBody( + buildAction({ + returnValue: { type: 'System.Void', typeSimple: 'void' }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('registers a query parameter via the binding source', () => { + const body = mapBody( + buildAction({ + parameters: [ + { + nameOnMethod: 'id', + name: 'id', + jsonName: null, + type: 'System.Guid', + typeSimple: 'string', + isOptional: false, + defaultValue: null, + constraintTypes: null, + bindingSourceId: eBindingSourceId.Query, + descriptorName: '', + }, + ], + }), + ); + + expect(body.params).toEqual(['id']); + }); +}); + +describe('createActionToBodyMapper — IsRemoteStream backend flag', () => { + const mapBody = createActionToBodyMapper(); + + test('isRemoteStream=true forces blob even if Type is a custom subclass name', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'My.Project.CustomStreamContent', + typeSimple: 'My.Project.CustomStreamContent', + isRemoteStream: true, + contentTypes: ['text/plain', 'application/json'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + expect(body.isBlobMethod()).toBe(true); + }); + + test('[Volo.Abp.Content.IRemoteStreamContent] (real ABP square-bracket form) must NOT pick blob and degrades responseType to any[]', () => { + // ABP serialises collections as `[T]` (not `T[]`) — pin the on-the-wire shape. + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.Collections.Generic.IList', + typeSimple: '[Volo.Abp.Content.IRemoteStreamContent]', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + expect(body.isBlobMethod()).toBe(false); + expect(body.responseType).toBe('any[]'); + }); + + test('IRemoteStreamContent[] array return must NOT pick blob (server falls back to JSON metadata)', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.IRemoteStreamContent[]', + typeSimple: 'Volo.Abp.Content.IRemoteStreamContent[]', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + expect(body.isBlobMethod()).toBe(false); + expect(body.responseType).toBe('any[]'); + }); + + test('isRemoteStream=false with stream-content type-name still detected by type name (legacy)', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.IRemoteStreamContent', + typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); +}); + +describe('createActionToBodyMapper — +json suffix detection', () => { + const mapBody = createActionToBodyMapper(); + + test('application/problem+json echoes back as Accept (decoder stays json)', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['application/problem+json'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/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), + ); + + 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), + ); + + 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), + ); + + expect(body.httpResponseType).toBe('blob'); + }); +}); + +describe('createActionToBodyMapper — contentTypes precedence and edge cases', () => { + const mapBody = createActionToBodyMapper(); + + test('isBlobMethod() type detection wins over json in contentTypes', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.RemoteStreamContent', + typeSimple: 'Volo.Abp.Content.RemoteStreamContent', + contentTypes: ['application/json', 'text/plain'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); + + test('IRemoteStreamContent[] array 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), + ); + + 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), + ); + + 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), + ); + + 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), + ); + + expect(body.httpResponseType).toBe('blob'); + }); + + test('application/pdf contentTypes picked as blob', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.Byte[]', + typeSimple: 'byte[]', + contentTypes: ['application/pdf'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + }); + + test('empty contentTypes falls through to legacy string→text behavior', () => { + const body = mapBody( + buildAction({ + returnValue: { type: 'System.String', typeSimple: 'string', contentTypes: [] }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('mixed text/* and application/json picks json 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), + ); + + 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), + ); + + expect(body.httpResponseType).toBe('json'); + }); + + test('non-string non-blob type with contentTypes containing json defaults appropriately', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'My.Project.UserDto', + typeSimple: 'My.Project.UserDto', + contentTypes: ['application/json'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('json contentType with charset parameter is normalized', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['application/json; charset=utf-8'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + expect(body.acceptHeader).toBe('application/json'); + }); + + test('text contentType with charset parameter is normalized', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['text/plain ; charset=utf-8 '], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBe('text/plain'); + }); + + test('mixed text/plain (with charset) and application/json picks json after normalize', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['text/plain; charset=utf-8', 'application/json; charset=utf-8'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('json'); + }); + + test('text/csv only (custom text format) picks text and echoes the content type back', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'System.String', + typeSimple: 'string', + contentTypes: ['text/csv'], + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBe('text/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['returnValue'], + } as Partial), + ); + + expect(body.httpResponseType).toBe('text'); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('legacy api-definition without contentTypes works (IRemoteStreamContent)', () => { + const body = mapBody( + buildAction({ + returnValue: { + type: 'Volo.Abp.Content.IRemoteStreamContent', + typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', + }, + } as Partial), + ); + + expect(body.httpResponseType).toBe('blob'); + expect(body.acceptHeader).toBe('application/octet-stream'); + }); + + test('legacy api-definition without contentTypes works (object)', () => { + const body = mapBody( + buildAction({ + returnValue: { type: 'My.Project.UserDto', typeSimple: 'My.Project.UserDto' }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + }); + + test('void / no return preserves legacy behavior', () => { + const body = mapBody( + buildAction({ + returnValue: { type: 'System.Void', typeSimple: 'void' }, + } as Partial), + ); + + expect(body.httpResponseType).toBeUndefined(); + expect(body.acceptHeader).toBeUndefined(); + }); +}); + +describe('proxy service template emission', () => { + const template = readFileSync(TEMPLATE_PATH, 'utf8'); + + test('reads body.httpResponseType and body.acceptHeader from body', () => { + expect(template).toContain('body.httpResponseType'); + expect(template).toContain('body.acceptHeader'); + }); + + test('emits Accept header conditional', () => { + expect(template).toMatch(/headers:\s*\{\s*Accept:/); + }); + + test('emits responseType only for non-json httpResponseType', () => { + expect(template).toMatch(/httpResponseType\s*&&\s*httpResponseType\s*!==\s*'json'/); + }); +}); + +describe('createActionToBodyMapper — multipart FormData uploads', () => { + const mapBody = createActionToBodyMapper(); + + test('DTO with one IRemoteStreamContent property collapses to FormData body using the method arg name', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + parameters: [ + { name: 'Name', nameOnMethod: 'input', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Form } as any, + { name: 'File', nameOnMethod: 'input', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial), + ); + + expect(body.body).toBe('input'); + expect(body.params).toEqual([]); + }); + + test('DTO with IEnumerable collapses to FormData body using the method arg name', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + parameters: [ + { name: 'Label', nameOnMethod: 'input', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Form } as any, + { name: 'Files', nameOnMethod: 'input', type: 'System.Collections.Generic.IEnumerable', typeSimple: '[Volo.Abp.Content.IRemoteStreamContent]', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial), + ); + + expect(body.body).toBe('input'); + expect(body.params).toEqual([]); + }); + + test('nested DTO with IRemoteStreamContent collapses to FormData body using the method arg name', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + parameters: [ + { name: 'Outer', nameOnMethod: 'input', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Form } as any, + { name: 'Child.File', nameOnMethod: 'input', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial), + ); + + expect(body.body).toBe('input'); + expect(body.params).toEqual([]); + }); + + test('direct IRemoteStreamContent parameter uses its own method arg name as the FormData body', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + parameters: [ + { name: 'file', nameOnMethod: 'file', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial), + ); + + expect(body.body).toBe('file'); + }); + + test('Path + upload-DTO mix keeps the path parameter while binding the upload to FormData', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + url: 'api/upload/{id}', + parameters: [ + { name: 'id', nameOnMethod: 'id', type: 'System.Int32', typeSimple: 'number', bindingSourceId: eBindingSourceId.Path } as any, + { name: 'Name', nameOnMethod: 'input', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Form } as any, + { name: 'File', nameOnMethod: 'input', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial), + ); + + expect(body.body).toBe('input'); + expect(body.url).toBe("`/api/upload/${id}`"); + }); + + test('actions without FormFile parameters are not affected', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + parameters: [ + { name: 'Name', nameOnMethod: 'name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Body } as any, + ], + } as Partial), + ); + + expect(body.body).toBe('name'); + }); +}); + +describe('createActionToBodyMapper — multipart upload params regression', () => { + const mapBody = createActionToBodyMapper(); + + test('AppService convention ModelBinding non-file field sharing the upload arg is dropped from query params', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + url: 'api/app/proxy-demo-test/upload-single', + parameters: [ + { nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, + { nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial), + ); + expect(body.body).toBe('input'); + expect(body.params.join(',')).not.toContain('name'); + }); + + test('Path param on a separate method arg stays in URL even when sibling form fields share the upload arg', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + url: 'api/proxy-demo/media/upload-with-path/{id}', + parameters: [ + { nameOnMethod: 'id', name: 'id', type: 'System.Int32', typeSimple: 'int', bindingSourceId: eBindingSourceId.Path } as any, + { nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, + { nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial), + ); + expect(body.body).toBe('input'); + expect(body.url).toContain('${id}'); + expect(body.params.join(',')).not.toContain('name'); + }); + + test('Multiple direct FormFile method args: body forwards only the first var (known limitation, mirrors jQuery)', () => { + const body = mapBody( + buildAction({ + httpMethod: 'POST', + url: 'api/proxy-demo/media/upload-two-direct', + parameters: [ + { nameOnMethod: 'file1', name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + { nameOnMethod: 'file2', name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial), + ); + expect(body.body).toBe('file1'); + }); +}); + +describe('createActionToSignatureMapper — multipart upload signature collapse', () => { + const mapSignature = createActionToSignatureMapper(); + + test('FormFile DTO method arg collapses to FormData type', () => { + const sig = mapSignature(buildAction({ + httpMethod: 'POST', + url: 'api/app/proxy-demo-test/upload-single', + parametersOnMethod: [ + { name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, + { nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial)); + const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(types).toContain('input:FormData'); + expect(types[types.length - 1]).toBe('config:Partial'); + }); + + test('Path arg keeps its primitive type while upload arg becomes FormData', () => { + const sig = mapSignature(buildAction({ + httpMethod: 'POST', + url: 'api/proxy-demo/media/upload-with-path/{id}', + parametersOnMethod: [ + { name: 'id', type: 'System.Int32', typeAsString: 'System.Int32', typeSimple: 'number', isOptional: false, defaultValue: null } as any, + { name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'id', name: 'id', type: 'System.Int32', typeSimple: 'int', bindingSourceId: eBindingSourceId.Path } as any, + { nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, + { nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial)); + const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(types).toEqual([ + 'id:number', + 'input:FormData', + 'config:Partial', + ]); + }); + + test('Multiple direct IRemoteStreamContent method args each become FormData independently', () => { + const sig = mapSignature(buildAction({ + httpMethod: 'POST', + url: 'api/proxy-demo/media/upload-two-direct', + parametersOnMethod: [ + { name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeAsString: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', isOptional: false, defaultValue: null } as any, + { name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeAsString: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'file1', name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + { nameOnMethod: 'file2', name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial)); + const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(types).toEqual([ + 'file1:FormData', + 'file2:FormData', + 'config:Partial', + ]); + }); + + test('Non-upload action signature stays untouched (regression guard)', () => { + const sig = mapSignature(buildAction({ + httpMethod: 'GET', + url: 'api/app/proxy-demo-test/get-item-by-id', + parametersOnMethod: [ + { name: 'id', type: 'System.Int32', typeAsString: 'System.Int32', typeSimple: 'number', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'id', name: 'id', type: 'System.Int32', typeSimple: 'int', bindingSourceId: eBindingSourceId.Path } as any, + ], + } as Partial)); + const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(types).toEqual(['id:number', 'config:Partial']); + }); +}); + +describe('createActionToMethodMapper — signature + body wired together for upload actions', () => { + const mapMethod = createActionToMethodMapper(); + + test('Upload action produces both FormData signature and FormData body in one pass', () => { + const method = mapMethod(buildAction({ + httpMethod: 'POST', + url: 'api/app/proxy-demo-test/upload-single', + parametersOnMethod: [ + { name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, + { nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial)); + const sigTypes = method.signature.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(sigTypes).toContain('input:FormData'); + expect(method.body.body).toBe('input'); + expect(method.body.params.join(',')).not.toContain('name'); + }); + + test('Query + ModelBinding + FormFile mix preserves Query as URL param and drops upload-arg fields', () => { + const method = mapMethod(buildAction({ + httpMethod: 'POST', + url: 'api/proxy-demo/media/upload-with-query', + parametersOnMethod: [ + { name: 'tag', type: 'System.String', typeAsString: 'System.String', typeSimple: 'string', isOptional: false, defaultValue: null } as any, + { name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any, + ], + parameters: [ + { nameOnMethod: 'tag', name: 'tag', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Query } as any, + { nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any, + { nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any, + ], + } as Partial)); + const sigTypes = method.signature.parameters.map((p: any) => `${p.name}:${p.type}`); + expect(sigTypes).toEqual(['tag:string', 'input:FormData', 'config:Partial']); + expect(method.body.body).toBe('input'); + expect(method.body.params).toContain('tag'); + expect(method.body.params.join(',')).not.toContain('name'); + }); +}); diff --git a/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts b/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts new file mode 100644 index 00000000000..04b070ae615 --- /dev/null +++ b/npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts @@ -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 { + const compiled = lodashTemplate(TEMPLATE_SRC, { + imports: { + camel: (s: string) => s.charAt(0).toLowerCase() + s.slice(1), + serializeParameters: (params: Array<{ name: string; type: string; default?: string }>) => + params.map(p => `${p.name}: ${p.type}`).join(', '), + }, + }); + return compiled(context); +} + +function buildContext(body: Partial) { + return { + apiName: 'Default', + name: 'Sample', + namespace: 'app', + imports: [ + { keyword: 'import', specifiers: ['RestService', 'Rest'], path: '@abp/ng.core' }, + { keyword: 'import', specifiers: ['Injectable', 'inject'], path: '@angular/core' }, + ], + methods: [ + { + body: makeBody(body), + signature: { + name: 'GetSampleAsync', + parameters: [], + }, + }, + ], + }; +} + +interface MockBody { + method: string; + url: string; + responseType: string; + responseTypeWithNamespace: string; + httpResponseType?: string; + acceptHeader?: string; + body?: string; + params: string[]; + dictParamVar?: string; + requestType: string; + isBlobMethod(): boolean; +} + +function makeBody(overrides: Partial): MockBody { + return { + method: 'GET', + url: "'/api/sample'", + responseType: 'any', + responseTypeWithNamespace: 'any', + httpResponseType: undefined, + acceptHeader: undefined, + body: undefined, + params: [], + dictParamVar: undefined, + requestType: 'any', + isBlobMethod: () => false, + ...overrides, + }; +} + +describe('proxy service template — rendered output', () => { + test('default JSON body emits no responseType and no Accept header', () => { + const output = render(buildContext({ + responseType: 'MyDto', + responseTypeWithNamespace: 'My.Project.MyDto', + })); + + expect(output).toContain("method: 'GET'"); + expect(output).not.toContain('responseType:'); + expect(output).not.toContain('headers:'); + }); + + test('json httpResponseType emits Accept but no responseType (default is json)', () => { + const output = render(buildContext({ + responseType: 'string', + responseTypeWithNamespace: 'string', + httpResponseType: 'json', + acceptHeader: 'application/json', + })); + + expect(output).toContain("headers: { Accept: 'application/json' }"); + expect(output).not.toContain('responseType:'); + }); + + test('text httpResponseType emits both responseType and Accept', () => { + const output = render(buildContext({ + responseType: 'string', + responseTypeWithNamespace: 'string', + httpResponseType: 'text', + acceptHeader: 'text/plain', + })); + + expect(output).toContain("responseType: 'text'"); + expect(output).toContain("headers: { Accept: 'text/plain' }"); + }); + + test('blob (IRemoteStreamContent) emits Blob return type + responseType + Accept', () => { + const output = render(buildContext({ + responseType: 'Volo.Abp.Content.IRemoteStreamContent', + responseTypeWithNamespace: 'Volo.Abp.Content.IRemoteStreamContent', + isBlobMethod: () => true, + httpResponseType: 'blob', + acceptHeader: 'application/octet-stream', + })); + + expect(output).toContain("responseType: 'blob'"); + expect(output).toContain('Blob>'); + expect(output).toContain("headers: { Accept: 'application/octet-stream' }"); + }); + + test('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 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); + ctx.methods[0].signature.parameters = [{ name: 'config', type: 'Record' } as any]; + const output = render(ctx); + + const abpStub = ` + declare module '@abp/ng.core' { + export namespace Rest { + export interface Config { + apiName?: string; + observe?: any; + skipHandleError?: boolean; + responseType?: string; + [key: string]: any; + } + export type Observe = any; + } + export class RestService { + request(req: any, config?: any): import('rxjs').Observable; + } + } + `; + const domStub = 'declare class Blob { constructor(parts?: any[], options?: any); }\n'; + const angularCoreStub = ` + declare module '@angular/core' { + export function Injectable(opts?: any): ClassDecorator; + export function inject(token: { new (...args: any[]): T }): T; + export function inject(token: any): T; + } + `; + const rxjsStub = ` + declare module 'rxjs' { + export class Observable { subscribe(...args: any[]): unknown; } + } + `; + + const ambient = abpStub + angularCoreStub + rxjsStub + domStub; + const sources: Record = { + '/proxy/sample.service.ts': output, + '/proxy/ambient.d.ts': ambient, + }; + + const compilerOptions: any = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.ES2020, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + experimentalDecorators: true, + emitDecoratorMetadata: true, + strict: true, + noEmit: true, + skipLibCheck: true, + }; + + const baseHost = ts.createCompilerHost(compilerOptions, true); + const host: any = { + ...baseHost, + getSourceFile: (fileName: string, languageVersion: any, onError: any) => { + if (sources[fileName]) { + return ts.createSourceFile(fileName, sources[fileName], languageVersion, true); + } + return baseHost.getSourceFile(fileName, languageVersion, onError); + }, + fileExists: (fileName: string) => + sources[fileName] != null || baseHost.fileExists(fileName), + readFile: (fileName: string) => + sources[fileName] ?? baseHost.readFile(fileName), + }; + + const program = ts.createProgram(Object.keys(sources), compilerOptions, host); + const errors = ts + .getPreEmitDiagnostics(program) + .filter((d: any) => + d.category === ts.DiagnosticCategory.Error && + 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' }, + ], + bodyOverrides: { method: 'POST', url: "'/api/test/upload-single'", body: 'input' }, + shouldContain: ['input: FormData', 'body: input'], + }, + { + name: 'direct upload — FormData arg with custom name', + signatureParams: [ + { name: 'file', type: 'FormData' }, + { name: 'config', type: 'Record' }, + ], + bodyOverrides: { method: 'POST', url: "'/api/test/upload-direct'", body: 'file' }, + shouldContain: ['file: FormData', 'body: file'], + }, + { + name: 'path + upload mixed — id stays in URL, FormData becomes body', + signatureParams: [ + { name: 'id', type: 'number' }, + { name: 'input', type: 'FormData' }, + { name: 'config', type: 'Record' }, + ], + bodyOverrides: { method: 'POST', url: '`/api/test/upload-with-path/${id}`', body: 'input' }, + shouldContain: ['id: number', 'input: FormData', 'body: input'], + }, + { + name: 'query + upload mixed — tag in params, FormData in body', + signatureParams: [ + { name: 'tag', type: 'string' }, + { name: 'input', type: 'FormData' }, + { name: 'config', type: 'Record' }, + ], + bodyOverrides: { + method: 'POST', + url: "'/api/test/upload-with-query'", + params: ['tag'], + body: 'input', + }, + shouldContain: ['tag: string', 'input: FormData', 'params: { tag }', 'body: input'], + }, + ])('upload action signature collapses to FormData ($name)', ({ signatureParams, bodyOverrides, shouldContain }) => { + const ts = require('typescript'); + const ctx = buildContext({ + responseType: 'string', + responseTypeWithNamespace: 'string', + ...bodyOverrides, + } as Partial); + ctx.methods[0].signature.parameters = signatureParams as any; + const output = render(ctx); + + for (const fragment of shouldContain) { + expect(output).toContain(fragment); + } + expect(output).not.toContain('JSON.stringify'); + + const abpStub = ` + declare module '@abp/ng.core' { + export namespace Rest { + export interface Config { + apiName?: string; + observe?: any; + skipHandleError?: boolean; + responseType?: string; + [key: string]: any; + } + export type Observe = any; + } + export class RestService { + request(req: any, config?: any): import('rxjs').Observable; + } + } + `; + const angularCoreStub = ` + declare module '@angular/core' { + export function Injectable(opts?: any): ClassDecorator; + export function inject(token: { new (...args: any[]): T }): T; + export function inject(token: any): T; + } + `; + const rxjsStub = ` + declare module 'rxjs' { + export class Observable { subscribe(...args: any[]): unknown; } + } + `; + const domStub = ` + declare class Blob { constructor(parts?: any[], options?: any); } + declare class FormData { + constructor(); + append(name: string, value: string | Blob, fileName?: string): void; + get(name: string): any; + } + `; + const ambient = abpStub + angularCoreStub + rxjsStub + domStub; + const sources: Record = { + '/proxy/sample.service.ts': output, + '/proxy/ambient.d.ts': ambient, + }; + const compilerOptions: any = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.ES2020, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + experimentalDecorators: true, + emitDecoratorMetadata: true, + strict: true, + noEmit: true, + skipLibCheck: true, + }; + const baseHost = ts.createCompilerHost(compilerOptions, true); + const host: any = { + ...baseHost, + getSourceFile: (fileName: string, languageVersion: any, onError: any) => + sources[fileName] + ? ts.createSourceFile(fileName, sources[fileName], languageVersion, true) + : baseHost.getSourceFile(fileName, languageVersion, onError), + fileExists: (fileName: string) => + sources[fileName] != null || baseHost.fileExists(fileName), + readFile: (fileName: string) => + sources[fileName] ?? baseHost.readFile(fileName), + }; + const program = ts.createProgram(Object.keys(sources), compilerOptions, host); + const errors = ts + .getPreEmitDiagnostics(program) + .filter((d: any) => d.category === ts.DiagnosticCategory.Error && d.code !== 6053); + if (errors.length) { + const messages = errors + .map((d: any) => { + const where = d.file + ? (() => { + const p = d.file.getLineAndCharacterOfPosition(d.start ?? 0); + const lineText = d.file.text.split('\n')[p.line]; + return `${d.file.fileName}:${p.line + 1}:${p.character + 1}\n>>> ${lineText}\n>>> ${' '.repeat(p.character)}^`; + })() + : '(no file)'; + return `[${where}] TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`; + }) + .join('\n---\n'); + throw new Error(`Upload action proxy did not compile:\n${output}\n=== diagnostics ===\n${messages}`); + } + expect(errors).toHaveLength(0); + }); + + test('rendered upload service forwards FormData to restService.request at runtime', () => { + const ts = require('typescript'); + const ctx = buildContext({ + method: 'POST', + url: "'/api/upload-runtime'", + responseType: 'string', + responseTypeWithNamespace: 'string', + body: 'input', + }); + ctx.methods[0].signature.parameters = [ + { name: 'input', type: 'FormData' }, + { name: 'config', type: 'Record' }, + ] as any; + const output = render(ctx); + + const stripped = output + .replace(/^import .*?;\s*$/gm, '') + .replace(/@Injectable\(\{[\s\S]*?\}\)\s*\n/g, '') + .replace(/private restService = inject\(RestService\);/, 'restService;') + .replace(/this\.restService\.request<[^>]+,\s*[^>]+>/g, 'this.restService.request'); + + const transpiled = ts.transpileModule(stripped, { + compilerOptions: { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + experimentalDecorators: true, + }, + }).outputText; + + const restMockCalls: Array<{ body: any; method: string; url: string; headers?: any }> = []; + const restMock = { + request: (req: any /* , _config: any */) => { + restMockCalls.push(req); + return { subscribe: () => undefined }; + }, + }; + + const vm = require('vm'); + const sandbox: Record = { exports: {} }; + vm.createContext(sandbox); + vm.runInContext(transpiled + '\nexports.SampleService = SampleService;', sandbox); + const ServiceCls = sandbox.exports.SampleService; + const instance = new ServiceCls(); + instance.restService = restMock; + + const GlobalFormData = (globalThis as any).FormData; + const formData = typeof GlobalFormData === 'function' + ? new GlobalFormData() + : { __isFormData: true, append: () => undefined }; + instance.getSampleAsync(formData, { apiName: 'Default' }); + + expect(restMockCalls).toHaveLength(1); + expect(restMockCalls[0].body).toBe(formData); + expect(restMockCalls[0].method).toBe('POST'); + }); +}); diff --git a/npm/ng-packs/packages/schematics/src/utils/service.ts b/npm/ng-packs/packages/schematics/src/utils/service.ts index 8fe4aae91a6..1c808c9d3b9 100644 --- a/npm/ng-packs/packages/schematics/src/utils/service.ts +++ b/npm/ng-packs/packages/schematics/src/utils/service.ts @@ -96,15 +96,135 @@ export function createActionToBodyMapper() { responseType = adaptType(normalizedType); } } + if (isRemoteStreamContentArray(returnValue.typeSimple)) { + responseType = 'any[]'; + } const responseTypeWithNamespace = returnValue.typeSimple; - const body = new Body({ method: httpMethod, responseType, url, responseTypeWithNamespace }); + const { httpResponseType, acceptHeader } = resolveHttpResponseAndAccept( + responseType, + responseTypeWithNamespace, + returnValue.contentTypes, + returnValue.isRemoteStream, + ); + const body = new Body({ + method: httpMethod, + responseType, + url, + responseTypeWithNamespace, + httpResponseType, + acceptHeader, + }); - parameters.forEach(body.registerActionParameter); + const uploadMethodArgNames = new Set( + parameters + .filter(p => p.bindingSourceId === eBindingSourceId.FormFile) + .map(p => p.nameOnMethod), + ); + if (uploadMethodArgNames.size > 0) { + body.body = camelizeHyphen([...uploadMethodArgNames][0]); + parameters + .filter(p => { + if (uploadMethodArgNames.has(p.nameOnMethod)) { + return false; + } + return ( + p.bindingSourceId !== eBindingSourceId.Form && + p.bindingSourceId !== eBindingSourceId.FormFile + ); + }) + .forEach(body.registerActionParameter); + } else { + parameters.forEach(body.registerActionParameter); + } return body; }; } +function normalizeMediaType(mediaType: string): string { + const semi = mediaType.indexOf(';'); + return (semi < 0 ? mediaType : mediaType.slice(0, semi)).trim().toLowerCase(); +} + +function isJsonMediaType(normalized: string): boolean { + return normalized === 'application/json' || normalized === 'text/json' || normalized.endsWith('+json'); +} + +function isBinaryMediaType(mediaType: string): boolean { + const m = normalizeMediaType(mediaType); + if ( + m === 'application/octet-stream' || + m === 'application/pdf' || + m === 'application/zip' || + m === 'application/x-zip-compressed' || + m === 'application/gzip' || + m === 'application/x-tar' || + m === 'application/x-7z-compressed' || + m === 'application/wasm' || + m === 'application/x-msdownload' || + m === 'application/x-msdos-program' || + m === 'application/rtf' || + m === 'application/x-rar-compressed' || + m === 'application/x-rar' || + m === 'application/x-bzip2' || + m === 'application/x-iso9660-image' || + m === 'application/x-apple-diskimage' || + m === 'application/java-archive' || + m === 'application/epub+zip' || + m === 'model/gltf-binary' + ) { + return true; + } + if (m.startsWith('image/') || m.startsWith('video/') || m.startsWith('audio/') || m.startsWith('font/')) { + return true; + } + // Office / OpenDocument / generic vnd.* (excluding +json / +xml which are structured text) + if ( + m.startsWith('application/vnd.openxmlformats-') || + m.startsWith('application/vnd.ms-') || + m.startsWith('application/vnd.oasis.opendocument.') + ) { + return true; + } + if (m.startsWith('application/vnd.') && !m.endsWith('+json') && !m.endsWith('+xml')) { + return true; + } + return false; +} + +function resolveHttpResponseAndAccept( + responseType: string, + responseTypeWithNamespace: string, + contentTypes: string[] | undefined, + isRemoteStreamFlag: boolean | undefined, +): { httpResponseType?: 'json' | 'text' | 'blob' | 'arraybuffer'; acceptHeader?: string } { + if (isRemoteStreamFlag || isRemoteStreamContent(responseTypeWithNamespace)) { + return { httpResponseType: 'blob', acceptHeader: 'application/octet-stream' }; + } + + if (contentTypes && contentTypes.length > 0) { + const normalized = contentTypes.map(normalizeMediaType); + + const firstJsonShaped = normalized.find(isJsonMediaType); + if (firstJsonShaped) { + return { httpResponseType: 'json', acceptHeader: firstJsonShaped }; + } + if (normalized.every(ct => ct.startsWith('text/'))) { + return { httpResponseType: 'text', acceptHeader: normalized[0] }; + } + if (normalized.every(isBinaryMediaType)) { + return { httpResponseType: 'blob', acceptHeader: normalized[0] }; + } + return { acceptHeader: normalized[0] }; + } + + if (responseType === 'string') { + return { httpResponseType: 'text' }; + } + + return {}; +} + export function createActionToSignatureMapper() { const adaptType = createTypeAdapter(); @@ -118,7 +238,16 @@ export function createActionToSignatureMapper() { ...(versionParameter ? [versionParameter] : []), ]; + const uploadMethodArgNames = new Set( + (action.parameters ?? []) + .filter(p => p.bindingSourceId === eBindingSourceId.FormFile) + .map(p => p.nameOnMethod), + ); + signature.parameters = parameters.map(p => { + if (uploadMethodArgNames.has(p.name)) { + return new Property({ name: p.name, type: 'FormData' }); + } const isFormData = isRemoteStreamContent(p.type); const isFormArray = isRemoteStreamContentArray(p.type); if (isFormData || isFormArray) { @@ -146,13 +275,18 @@ export function isRemoteStreamContent(type: string) { } export function isRemoteStreamContentArray(type: string) { - // Check for array types like Volo.Abp.Content.IRemoteStreamContent[] if (VOLO_REMOTE_STREAM_CONTENT.map(x => `${x}[]`).some(x => x === type)) { return true; } - // Check for collection types like List, IEnumerable, ICollection, Collection, IList - // This matches any generic type from System.Collections.Generic that implements IEnumerable + // ABP serialises collections as `[T]` (see ApiTypeNameHelper.GetSimpleTypeName). + if (type.startsWith('[') && type.endsWith(']')) { + const inner = type.slice(1, -1); + if (VOLO_REMOTE_STREAM_CONTENT.includes(inner)) { + return true; + } + } + if (isCollectionType(type)) { const { generics } = extractGenerics(type); if (generics.length > 0 && VOLO_REMOTE_STREAM_CONTENT.includes(generics[0])) {