Browse Source

Merge pull request #25639 from abpframework/maliming/proxy-content-types

Expose response ContentTypes in api-definition and use them in proxies
pull/25658/head
sumeyye 3 months ago
committed by GitHub
parent
commit
9a793c75be
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 24
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs
  2. 99
      framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs
  3. 4
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs
  4. 17
      framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel.cs
  5. 69
      framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs
  6. 275
      framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase_ContentTypes_Tests.cs
  7. 804
      framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyRequestPayloadBuilder_FormData_Tests.cs
  8. 21
      framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/IRegularTestController.cs
  9. 84
      framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/PeopleAppServiceClientProxy_ReturnContentTypes_Tests.cs
  10. 79
      framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestController.cs
  11. 103
      framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/DynamicProxying/RegularTestControllerClientProxy_ReturnContentTypes_Tests.cs
  12. 279
      framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/Modeling/ReturnValueApiDescriptionModel_Tests.cs
  13. 266
      framework/test/Volo.Abp.Http.Tests/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator_ContentTypes_Tests.cs
  14. 8
      framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IPeopleAppService.cs
  15. 25
      framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/PeopleAppService.cs
  16. 109
      npm/ng-packs/packages/core/src/lib/services/rest.service.ts
  17. 362
      npm/ng-packs/packages/core/src/lib/tests/rest.service.spec.ts
  18. 10
      npm/ng-packs/packages/schematics/src/commands/api/files-service/proxy/__namespace@dir__/__name@kebab__.service.ts.template
  19. 1
      npm/ng-packs/packages/schematics/src/enums/binding-source-id.ts
  20. 7
      npm/ng-packs/packages/schematics/src/models/api-definition.ts
  21. 5
      npm/ng-packs/packages/schematics/src/models/method.ts
  22. 846
      npm/ng-packs/packages/schematics/src/tests/action-to-body-mapper.spec.ts
  23. 495
      npm/ng-packs/packages/schematics/src/tests/proxy-service-template-render.spec.ts
  24. 144
      npm/ng-packs/packages/schematics/src/utils/service.ts

24
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AspNetCoreApiDescriptionModelProvider.cs

@ -175,7 +175,8 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
GetSupportedVersions(controllerType, method, setting),
allowAnonymous,
authorizeModels,
implementFrom
implementFrom,
GetReturnValueContentTypes(apiDescription)
)
);
@ -199,6 +200,27 @@ public class AspNetCoreApiDescriptionModelProvider : IApiDescriptionModelProvide
}
}
private static List<string>? 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<string> GetSupportedVersions(Type controllerType, MethodInfo method,
ConventionalControllerSetting? setting)
{

99
framework/src/Volo.Abp.Http.Client/Volo/Abp/Http/Client/ClientProxying/ClientProxyBase.cs

@ -108,8 +108,7 @@ public class ClientProxyBase<TService> : ITransientDependency
{
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 @@ public class ClientProxyBase<TService> : ITransientDependency
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 @@ public class ClientProxyBase<TService> : ITransientDependency
}
}
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<string>(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<HttpContent> 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 @@ public class ClientProxyBase<TService> : ITransientDependency
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 @@ public class ClientProxyBase<TService> : ITransientDependency
}
}
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] == '"')

4
framework/src/Volo.Abp.Http/Volo/Abp/Http/Modeling/ActionApiDescriptionModel.cs

@ -45,7 +45,7 @@ public class ActionApiDescriptionModel
}
public static ActionApiDescriptionModel Create([NotNull] string uniqueName, [NotNull] MethodInfo method, [NotNull] string url, string? httpMethod, [NotNull] IList<string> supportedVersions, bool? allowAnonymous = null, IList<AuthorizeDataApiDescriptionModel>? authorizeDatas = null, string? implementFrom = null)
public static ActionApiDescriptionModel Create([NotNull] string uniqueName, [NotNull] MethodInfo method, [NotNull] string url, string? httpMethod, [NotNull] IList<string> supportedVersions, bool? allowAnonymous = null, IList<AuthorizeDataApiDescriptionModel>? authorizeDatas = null, string? implementFrom = null, IList<string>? returnValueContentTypes = null)
{
Check.NotNull(uniqueName, nameof(uniqueName));
Check.NotNull(method, nameof(method));
@ -58,7 +58,7 @@ public class ActionApiDescriptionModel
Name = method.Name,
Url = url,
HttpMethod = httpMethod,
ReturnValue = ReturnValueApiDescriptionModel.Create(method.ReturnType),
ReturnValue = ReturnValueApiDescriptionModel.Create(method.ReturnType, returnValueContentTypes),
Parameters = new List<ParameterApiDescriptionModel>(),
ParametersOnMethod = method
.GetParameters()

17
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<string>? ContentTypes { get; set; }
public bool IsRemoteStream { get; set; }
public ReturnValueApiDescriptionModel()
{
}
public static ReturnValueApiDescriptionModel Create(Type type)
public static ReturnValueApiDescriptionModel Create(Type type, IList<string>? 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);
}
}

69
framework/src/Volo.Abp.Http/Volo/Abp/Http/ProxyScripting/Generators/JQuery/JQueryProxyScriptGenerator.cs

@ -135,10 +135,9 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen
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 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen
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 @@ public class JQueryProxyScriptGenerator : IProxyScriptGenerator, ITransientDepen
script.Append(" headers: " + headers);
}
var firstFileParam = action.Parameters.FirstOrDefault(p => p.BindingSourceId == ParameterBindingSources.FormFile);
if (firstFileParam != null)
{
var fileVar = ProxyScriptingJsFuncHelper.NormalizeJsVariableName(firstFileParam.NameOnMethod.ToCamelCase());
script.AppendLine(",");
script.Append(" data: " + fileVar + ",");
script.AppendLine();
script.Append(" processData: false,");
script.AppendLine();
script.Append(" contentType: false");
script.AppendLine();
return;
}
var body = ProxyScriptingHelper.GenerateBody(action);
if (!body.IsNullOrEmpty())
{

275
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<string>? contentTypes)
{
return new ActionApiDescriptionModel
{
UniqueName = "Sample",
Name = "Sample",
HttpMethod = "GET",
Url = "api/test",
SupportedVersions = new List<string>(),
ParametersOnMethod = new List<MethodParameterApiDescriptionModel>(),
Parameters = new List<ParameterApiDescriptionModel>(),
ReturnValue = new ReturnValueApiDescriptionModel
{
Type = returnType,
TypeSimple = returnType,
ContentTypes = contentTypes
},
AuthorizeDatas = new List<AuthorizeDataApiDescriptionModel>()
};
}
[Fact]
public void AddHeaders_With_ApiVersion_Should_Combine_OctetStream_Accept_With_Version_Suffix()
{
var action = BuildAction(
returnType: typeof(IRemoteStreamContent).FullName!,
contentTypes: new[] { "application/json", "text/plain" });
var headers = InvokeAddHeadersAndCollectAccept(action, version: "2.0");
headers.ShouldContain("application/octet-stream; v=2.0");
headers.ShouldNotContain(h => h == "text/plain; v=2.0");
headers.ShouldNotContain(h => h == "application/json; v=2.0");
}
[Fact]
public void AddHeaders_Without_ApiVersion_Should_Emit_OctetStream_For_Stream_Returns()
{
var action = BuildAction(
returnType: typeof(IRemoteStreamContent).FullName!,
contentTypes: new[] { "application/json" });
var headers = InvokeAddHeadersAndCollectAccept(action, version: null);
headers.ShouldContain("application/octet-stream");
}
[Fact]
public void AddHeaders_Without_ContentType_Metadata_Should_Fall_Back_To_Text_And_Json_Pair()
{
var action = BuildAction(returnType: "System.Int32", contentTypes: null);
var headers = InvokeAddHeadersAndCollectAccept(action, version: "1.0");
headers.ShouldContain("text/plain; v=1.0");
headers.ShouldContain("application/json; v=1.0");
}
[Fact]
public void AddHeaders_Without_ApiVersion_And_Without_ContentType_Metadata_Should_Emit_Unversioned_Text_Json_Pair()
{
var action = BuildAction(returnType: "System.Int32", contentTypes: null);
var headers = InvokeAddHeadersAndCollectAccept(action, version: null);
headers.ShouldContain("text/plain");
headers.ShouldContain("application/json");
headers.ShouldNotContain(h => h.Contains("; v="));
}
private static IList<string> InvokeAddHeadersAndCollectAccept(ActionApiDescriptionModel action, string? version)
{
var proxy = new TestableClientProxy();
var message = new HttpRequestMessage(HttpMethod.Get, "http://localhost/x");
var apiVersion = new ApiVersionInfo("HeaderModelBinding", version ?? string.Empty);
proxy.PublicAddAcceptHeaders(action, message, apiVersion);
return message.Headers.Accept.Select(a => a.ToString()).ToList();
}
private sealed class TestableClientProxy : ClientProxyBase<object>
{
public string? PublicGetAcceptForActionReturn(ActionApiDescriptionModel action)
=> GetAcceptForActionReturn(action);
public void PublicAddAcceptHeaders(ActionApiDescriptionModel action, HttpRequestMessage requestMessage, ApiVersionInfo apiVersion)
=> AddAcceptHeaders(action, requestMessage, apiVersion);
}
}

804
framework/test/Volo.Abp.Http.Client.Tests/Volo/Abp/Http/Client/ClientProxying/ClientProxyRequestPayloadBuilder_FormData_Tests.cs

@ -0,0 +1,804 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Volo.Abp.Content;
using Volo.Abp.Http.Modeling;
using Volo.Abp.Http.ProxyScripting.Generators;
using Volo.Abp.Json;
using Volo.Abp.Timing;
using Xunit;
using MicrosoftOptions = Microsoft.Extensions.Options.Options;
namespace Volo.Abp.Http.Client.ClientProxying;
public class ClientProxyRequestPayloadBuilder_FormData_Tests
{
private readonly ClientProxyRequestPayloadBuilder _builder;
private readonly IJsonSerializer _jsonSerializer = new StubJsonSerializer();
private static readonly ApiVersionInfo NoApiVersion = new("Query", "1.0");
public ClientProxyRequestPayloadBuilder_FormData_Tests()
{
var services = new ServiceCollection();
var scopeFactory = services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
var options = MicrosoftOptions.Create(new AbpHttpClientProxyingOptions());
_builder = new ClientProxyRequestPayloadBuilder(scopeFactory, options, new TestClock());
}
[Fact]
public async Task Direct_IRemoteStreamContent_Param_Should_Produce_Single_StreamContent_Part()
{
var action = BuildAction(parameters: new[]
{
FormFileParam(name: "file", nameOnMethod: "file"),
});
var stream = MakeStream("hello-direct");
var args = new Dictionary<string, object?>
{
["file"] = new RemoteStreamContent(stream, "demo.txt", "text/plain"),
};
var content = await InvokeAsync(action, args);
var multipart = content.ShouldBeOfType<MultipartFormDataContent>();
var parts = multipart.ToList();
parts.Count.ShouldBe(1);
parts[0].Headers.ContentType!.MediaType.ShouldBe("text/plain");
(await parts[0].ReadAsStringAsync()).ShouldBe("hello-direct");
}
[Fact]
public async Task Dto_With_IRemoteStreamContent_Property_Should_Flatten_To_Name_Plus_File_Parts()
{
var action = BuildAction(parameters: new[]
{
FormParam(name: "Name", nameOnMethod: "input"),
FormFileParam(name: "File", nameOnMethod: "input"),
});
var args = new Dictionary<string, object?>
{
["input"] = new TestUploadDto
{
Name = "Alice",
File = new RemoteStreamContent(MakeStream("hello-single"), "single.txt", "text/plain"),
},
};
var content = await InvokeAsync(action, args);
var multipart = content.ShouldBeOfType<MultipartFormDataContent>();
var parts = multipart.ToList();
parts.Count.ShouldBe(2);
await AssertStringPart(parts[0], "Name", "Alice");
await AssertStreamPart(parts[1], "File", "hello-single", "text/plain");
}
[Fact]
public async Task Dto_With_IEnumerable_IRemoteStreamContent_Should_Emit_One_Part_Per_Stream()
{
var action = BuildAction(parameters: new[]
{
FormParam(name: "Label", nameOnMethod: "input"),
FormFileParam(name: "Files", nameOnMethod: "input"),
});
var args = new Dictionary<string, object?>
{
["input"] = new TestUploadFilesDto
{
Label = "batch",
Files = new[]
{
new RemoteStreamContent(MakeStream("a-content"), "a.txt", "text/plain"),
new RemoteStreamContent(MakeStream("b-content"), "b.txt", "text/csv"),
},
},
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList();
parts.Count.ShouldBe(3);
await AssertStringPart(parts[0], "Label", "batch");
await AssertStreamPart(parts[1], "Files", "a-content", "text/plain");
await AssertStreamPart(parts[2], "Files", "b-content", "text/csv");
}
[Fact]
public async Task Nested_Dto_With_Child_File_Path_Should_Be_Reflected_Via_Dotted_Name()
{
var action = BuildAction(parameters: new[]
{
FormParam(name: "Outer", nameOnMethod: "input"),
FormFileParam(name: "Child.File", nameOnMethod: "input"),
});
var args = new Dictionary<string, object?>
{
["input"] = new TestNestedUploadDto
{
Outer = "outerVal",
Child = new TestNestedChildDto
{
File = new RemoteStreamContent(MakeStream("hello-nested"), "nested.txt", "text/plain"),
},
},
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList();
parts.Count.ShouldBe(2);
await AssertStringPart(parts[0], "Outer", "outerVal");
await AssertStreamPart(parts[1], "Child.File", "hello-nested", "text/plain");
}
[Fact]
public async Task Form_Only_Action_Without_FormFile_Should_Still_Produce_Multipart_With_String_Parts()
{
var action = BuildAction(parameters: new[]
{
FormParam(name: "Name", nameOnMethod: "input"),
FormParam(name: "Tag", nameOnMethod: "input"),
});
var args = new Dictionary<string, object?>
{
["input"] = new TestUploadDto { Name = "Alice", Tag = "T1" },
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList();
parts.Count.ShouldBe(2);
await AssertStringPart(parts[0], "Name", "Alice");
await AssertStringPart(parts[1], "Tag", "T1");
}
[Fact]
public async Task Body_Binding_Wins_Over_Form_And_Returns_Json_StringContent()
{
var action = BuildAction(parameters: new[]
{
new ParameterApiDescriptionModel
{
Name = "input",
NameOnMethod = "input",
Type = typeof(TestUploadDto).FullName!,
TypeSimple = "dto",
BindingSourceId = ParameterBindingSources.Body,
},
});
var args = new Dictionary<string, object?>
{
["input"] = new TestUploadDto { Name = "Alice" },
};
var content = await InvokeAsync(action, args);
var stringContent = content.ShouldBeOfType<StringContent>();
stringContent.Headers.ContentType!.MediaType.ShouldBe("application/json");
var body = await stringContent.ReadAsStringAsync();
body.ShouldContain("\"Name\":\"Alice\"");
}
[Fact]
public async Task No_Form_Or_Body_Params_Should_Return_Null_Content()
{
var action = BuildAction(parameters: new[]
{
new ParameterApiDescriptionModel
{
Name = "id",
NameOnMethod = "id",
Type = "System.Int32",
TypeSimple = "int",
BindingSourceId = ParameterBindingSources.Path,
},
});
var args = new Dictionary<string, object?> { ["id"] = 42 };
var content = await InvokeAsync(action, args);
content.ShouldBeNull();
}
[Fact]
public async Task FormFile_With_Null_Value_Should_Be_Skipped_Not_Throw()
{
var action = BuildAction(parameters: new[]
{
FormParam(name: "Name", nameOnMethod: "input"),
FormFileParam(name: "File", nameOnMethod: "input"),
});
var args = new Dictionary<string, object?>
{
["input"] = new TestUploadDto { Name = "Alice", File = null },
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList();
parts.Count.ShouldBe(1);
await AssertStringPart(parts[0], "Name", "Alice");
}
[Fact]
public async Task Three_Level_Nested_Dto_Should_Resolve_Outer_Inner_File_Via_Dotted_Path()
{
var action = BuildAction(parameters: new[]
{
FormParam(name: "Outer", nameOnMethod: "input"),
FormFileParam(name: "Inner.Child.File", nameOnMethod: "input"),
});
var args = new Dictionary<string, object?>
{
["input"] = new TestThreeLevelDto
{
Outer = "outerVal",
Inner = new TestThreeLevelMiddleDto
{
Child = new TestNestedChildDto
{
File = new RemoteStreamContent(MakeStream("hello-3-levels"), "deep.txt", "text/plain"),
},
},
},
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList();
parts.Count.ShouldBe(2);
await AssertStringPart(parts[0], "Outer", "outerVal");
await AssertStreamPart(parts[1], "Inner.Child.File", "hello-3-levels", "text/plain");
}
[Fact]
public async Task File_With_UTF8_FileName_Should_Survive_To_Content_Disposition_FileName_Star()
{
var action = BuildAction(parameters: new[]
{
FormFileParam(name: "file", nameOnMethod: "file"),
});
var args = new Dictionary<string, object?>
{
["file"] = new RemoteStreamContent(MakeStream("hello-utf8"), "中文-文件名.txt", "text/plain"),
};
var content = await InvokeAsync(action, args);
var part = content.ShouldBeOfType<MultipartFormDataContent>().Single();
var disposition = part.Headers.ContentDisposition;
disposition.ShouldNotBeNull();
disposition!.FileNameStar.ShouldBe("中文-文件名.txt");
(await part.ReadAsStringAsync()).ShouldBe("hello-utf8");
}
[Fact]
public async Task Dto_Treated_As_Body_When_Not_Registered_Should_Serialize_As_Json_With_File_Field_Embedded()
{
var action = BuildAction(parameters: new[]
{
new ParameterApiDescriptionModel
{
Name = "input",
NameOnMethod = "input",
Type = typeof(TestUploadDto).FullName!,
TypeSimple = "dto",
BindingSourceId = ParameterBindingSources.Body,
},
});
var args = new Dictionary<string, object?>
{
["input"] = new TestUploadDto
{
Name = "Alice",
File = new RemoteStreamContent(MakeStream("ignored"), "x.txt", "text/plain"),
},
};
var content = await InvokeAsync(action, args);
var stringContent = content.ShouldBeOfType<StringContent>();
stringContent.Headers.ContentType!.MediaType.ShouldBe("application/json");
var body = await stringContent.ReadAsStringAsync();
body.ShouldContain("\"Name\":\"Alice\"");
body.ShouldContain("\"File\"");
}
[Fact]
public async Task Dto_With_Both_Stream_Property_And_Stream_Collection_Should_Emit_All_Parts()
{
var action = BuildAction(parameters: new[]
{
FormParam(name: "Label", nameOnMethod: "input"),
FormFileParam(name: "Main", nameOnMethod: "input"),
FormFileParam(name: "Extras", nameOnMethod: "input"),
});
var args = new Dictionary<string, object?>
{
["input"] = new TestMixedUploadDto
{
Label = "combo",
Main = new RemoteStreamContent(MakeStream("main-body"), "main.txt", "text/plain"),
Extras = new[]
{
new RemoteStreamContent(MakeStream("extra-a"), "ea.txt", "text/csv"),
new RemoteStreamContent(MakeStream("extra-b"), "eb.txt", "text/plain"),
},
},
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList();
parts.Count.ShouldBe(4);
await AssertStringPart(parts[0], "Label", "combo");
await AssertStreamPart(parts[1], "Main", "main-body", "text/plain");
await AssertStreamPart(parts[2], "Extras", "extra-a", "text/csv");
await AssertStreamPart(parts[3], "Extras", "extra-b", "text/plain");
}
[Fact]
public async Task Two_Different_Upload_Actions_On_Same_Builder_Should_Not_Pollute_Each_Other()
{
var actionA = BuildAction(parameters: new[]
{
FormParam(name: "Name", nameOnMethod: "input"),
FormFileParam(name: "File", nameOnMethod: "input"),
});
var actionB = BuildAction(parameters: new[]
{
FormParam(name: "Label", nameOnMethod: "input"),
FormFileParam(name: "Files", nameOnMethod: "input"),
});
var argsA = new Dictionary<string, object?>
{
["input"] = new TestUploadDto { Name = "A", File = new RemoteStreamContent(MakeStream("aa"), "a.txt", "text/plain") },
};
var argsB = new Dictionary<string, object?>
{
["input"] = new TestUploadFilesDto
{
Label = "B",
Files = new[] { new RemoteStreamContent(MakeStream("bb"), "b.txt", "text/csv") },
},
};
var contentA = (await InvokeAsync(actionA, argsA)).ShouldBeOfType<MultipartFormDataContent>().ToList();
var contentB = (await InvokeAsync(actionB, argsB)).ShouldBeOfType<MultipartFormDataContent>().ToList();
await AssertStringPart(contentA[0], "Name", "A");
await AssertStreamPart(contentA[1], "File", "aa", "text/plain");
await AssertStringPart(contentB[0], "Label", "B");
await AssertStreamPart(contentB[1], "Files", "bb", "text/csv");
}
[Fact]
public async Task Inherited_Dto_With_File_Property_On_Base_Class_Should_Resolve_Via_Reflection()
{
var action = BuildAction(parameters: new[]
{
FormParam(name: "Name", nameOnMethod: "input"),
FormFileParam(name: "File", nameOnMethod: "input"),
FormParam(name: "ChildOnly", nameOnMethod: "input"),
});
var args = new Dictionary<string, object?>
{
["input"] = new TestInheritedUploadDto
{
Name = "inherited",
File = new RemoteStreamContent(MakeStream("from-base"), "base.txt", "text/plain"),
ChildOnly = "extra",
},
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList();
parts.Count.ShouldBe(3);
await AssertStringPart(parts[0], "Name", "inherited");
await AssertStreamPart(parts[1], "File", "from-base", "text/plain");
await AssertStringPart(parts[2], "ChildOnly", "extra");
}
[Fact]
public async Task Record_Dto_With_File_Property_Should_Resolve_Via_Reflection()
{
var action = BuildAction(parameters: new[]
{
FormParam(name: "Name", nameOnMethod: "input"),
FormFileParam(name: "File", nameOnMethod: "input"),
});
var args = new Dictionary<string, object?>
{
["input"] = new TestRecordUploadDto(
Name: "Diana",
File: new RemoteStreamContent(MakeStream("from-record"), "r.txt", "text/plain")),
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList();
parts.Count.ShouldBe(2);
await AssertStringPart(parts[0], "Name", "Diana");
await AssertStreamPart(parts[1], "File", "from-record", "text/plain");
}
[Fact]
public async Task Dto_With_DateTime_Enum_And_Nullable_Struct_Fields_Should_Round_Trip_As_String_Parts()
{
var action = BuildAction(parameters: new[]
{
FormParam(name: "When", nameOnMethod: "input"),
FormParam(name: "Status", nameOnMethod: "input"),
FormParam(name: "Quantity", nameOnMethod: "input"),
FormFileParam(name: "File", nameOnMethod: "input"),
});
var args = new Dictionary<string, object?>
{
["input"] = new TestPrimitiveUploadDto
{
When = new DateTime(2025, 6, 1, 12, 34, 56, DateTimeKind.Utc),
Status = TestStatus.Active,
Quantity = 7,
File = new RemoteStreamContent(MakeStream("primitives"), "p.txt", "text/plain"),
},
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList();
parts.Count.ShouldBe(4);
(await parts[0].ReadAsStringAsync()).ShouldStartWith("2025-06-01T12:34:56");
(await parts[1].ReadAsStringAsync()).ShouldBe("Active");
(await parts[2].ReadAsStringAsync()).ShouldBe("7");
await AssertStreamPart(parts[3], "File", "primitives", "text/plain");
}
[Fact]
public async Task Dto_With_Nullable_Struct_Field_Set_To_Null_Should_Skip_The_Part_Entirely()
{
var action = BuildAction(parameters: new[]
{
FormParam(name: "Quantity", nameOnMethod: "input"),
FormFileParam(name: "File", nameOnMethod: "input"),
});
var args = new Dictionary<string, object?>
{
["input"] = new TestPrimitiveUploadDto
{
Quantity = null,
File = new RemoteStreamContent(MakeStream("null-qty"), "n.txt", "text/plain"),
},
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList();
parts.Count.ShouldBe(1);
await AssertStreamPart(parts[0], "File", "null-qty", "text/plain");
}
[Fact]
public async Task Polymorphic_Dto_With_Derived_Type_Should_Reflect_Properties_From_Concrete_Runtime_Type()
{
var action = BuildAction(parameters: new[]
{
FormParam(name: "Name", nameOnMethod: "input"),
FormParam(name: "ExtraField", nameOnMethod: "input"),
FormFileParam(name: "File", nameOnMethod: "input"),
});
TestUploadDtoBase polymorphic = new TestPolymorphicDerivedDto
{
Name = "poly",
ExtraField = "derived-only",
File = new RemoteStreamContent(MakeStream("from-derived"), "d.txt", "text/plain"),
};
var args = new Dictionary<string, object?> { ["input"] = polymorphic };
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList();
parts.Count.ShouldBe(3);
await AssertStringPart(parts[0], "Name", "poly");
await AssertStringPart(parts[1], "ExtraField", "derived-only");
await AssertStreamPart(parts[2], "File", "from-derived", "text/plain");
}
[Fact]
public async Task Generic_Dto_Closed_Over_Concrete_Type_Should_Reflect_Open_Generic_Property()
{
var action = BuildAction(parameters: new[]
{
FormParam(name: "Payload", nameOnMethod: "input"),
FormFileParam(name: "File", nameOnMethod: "input"),
});
var args = new Dictionary<string, object?>
{
["input"] = new TestGenericUploadDto<string>
{
Payload = "closed-string",
File = new RemoteStreamContent(MakeStream("g.txt-body"), "g.txt", "text/plain"),
},
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList();
parts.Count.ShouldBe(2);
await AssertStringPart(parts[0], "Payload", "closed-string");
await AssertStreamPart(parts[1], "File", "g.txt-body", "text/plain");
}
[Fact]
public async Task Generic_Dto_With_Integer_Payload_Should_Convert_Via_ConvertValueToString()
{
var action = BuildAction(parameters: new[]
{
FormParam(name: "Payload", nameOnMethod: "input"),
FormFileParam(name: "File", nameOnMethod: "input"),
});
var args = new Dictionary<string, object?>
{
["input"] = new TestGenericUploadDto<int>
{
Payload = 42,
File = new RemoteStreamContent(MakeStream("int-payload"), "i.txt", "text/plain"),
},
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList();
parts.Count.ShouldBe(2);
await AssertStringPart(parts[0], "Payload", "42");
}
[Fact]
public async Task Underlying_Caller_Stream_Is_Wrapped_Without_Buffering_So_Retry_Sees_Drained_Source()
{
// Pin pass-through behaviour: HttpClient retry that re-reads `sourceStream`
// observes an empty body (no internal buffering). Opting in to buffering
// later would double-allocate for large uploads.
var action = BuildAction(parameters: new[]
{
FormFileParam(name: "file", nameOnMethod: "file"),
});
var sourceStream = MakeStream("retry-payload");
var args = new Dictionary<string, object?>
{
["file"] = new RemoteStreamContent(sourceStream, "r.txt", "text/plain"),
};
var content = await InvokeAsync(action, args);
var part = content.ShouldBeOfType<MultipartFormDataContent>().Single();
sourceStream.Position.ShouldBe(0);
(await part.ReadAsStringAsync()).ShouldBe("retry-payload");
sourceStream.Position.ShouldBe(sourceStream.Length);
}
[Fact]
public async Task Action_With_No_Parameters_Should_Return_Null_Content_Without_Throwing()
{
var action = BuildAction(parameters: Array.Empty<ParameterApiDescriptionModel>());
var args = new Dictionary<string, object?>();
var content = await InvokeAsync(action, args);
content.ShouldBeNull();
}
[Fact]
public async Task Path_Plus_Form_Plus_FormFile_Should_Skip_Path_And_Emit_Multipart_Only()
{
var action = BuildAction(parameters: new[]
{
new ParameterApiDescriptionModel
{
Name = "id",
NameOnMethod = "id",
Type = "System.Int32",
TypeSimple = "int",
BindingSourceId = ParameterBindingSources.Path,
},
FormParam(name: "Name", nameOnMethod: "input"),
FormFileParam(name: "File", nameOnMethod: "input"),
});
var args = new Dictionary<string, object?>
{
["id"] = 7,
["input"] = new TestUploadDto
{
Name = "Bob",
File = new RemoteStreamContent(MakeStream("path-mixed"), "p.txt", "text/plain"),
},
};
var content = await InvokeAsync(action, args);
var parts = content.ShouldBeOfType<MultipartFormDataContent>().ToList();
parts.Count.ShouldBe(2);
await AssertStringPart(parts[0], "Name", "Bob");
await AssertStreamPart(parts[1], "File", "path-mixed", "text/plain");
}
private Task<HttpContent?> InvokeAsync(ActionApiDescriptionModel action, IReadOnlyDictionary<string, object?> args)
=> _builder.BuildContentAsync(action, args, _jsonSerializer, NoApiVersion);
private static ActionApiDescriptionModel BuildAction(ParameterApiDescriptionModel[] parameters)
=> new()
{
UniqueName = "TestAction",
Name = "TestAction",
HttpMethod = "POST",
Url = "api/test",
SupportedVersions = new List<string>(),
ParametersOnMethod = new List<MethodParameterApiDescriptionModel>(),
Parameters = parameters.ToList(),
ReturnValue = new ReturnValueApiDescriptionModel
{
Type = "System.String",
TypeSimple = "string",
},
AuthorizeDatas = new List<AuthorizeDataApiDescriptionModel>(),
};
private static ParameterApiDescriptionModel FormParam(string name, string nameOnMethod)
=> new()
{
Name = name,
NameOnMethod = nameOnMethod,
Type = "System.String",
TypeSimple = "string",
BindingSourceId = ParameterBindingSources.Form,
};
private static ParameterApiDescriptionModel FormFileParam(string name, string nameOnMethod)
=> new()
{
Name = name,
NameOnMethod = nameOnMethod,
Type = typeof(IRemoteStreamContent).FullName!,
TypeSimple = "stream",
BindingSourceId = ParameterBindingSources.FormFile,
};
private static MemoryStream MakeStream(string text)
{
var ms = new MemoryStream();
ms.Write(Encoding.UTF8.GetBytes(text));
ms.Position = 0;
return ms;
}
private static async Task AssertStringPart(HttpContent part, string expectedName, string expectedValue)
{
var disposition = part.Headers.ContentDisposition;
disposition.ShouldNotBeNull();
disposition!.Name!.Trim('"').ShouldBe(expectedName);
(await part.ReadAsStringAsync()).ShouldBe(expectedValue);
}
private static async Task AssertStreamPart(HttpContent part, string expectedName, string expectedBody, string expectedContentType)
{
var disposition = part.Headers.ContentDisposition;
disposition.ShouldNotBeNull();
disposition!.Name!.Trim('"').ShouldBe(expectedName);
part.Headers.ContentType!.MediaType.ShouldBe(expectedContentType);
(await part.ReadAsStringAsync()).ShouldBe(expectedBody);
}
private class TestUploadDto
{
public string? Name { get; set; }
public string? Tag { get; set; }
public IRemoteStreamContent? File { get; set; }
}
private class TestUploadFilesDto
{
public string? Label { get; set; }
public IEnumerable<IRemoteStreamContent>? Files { get; set; }
}
private class TestNestedUploadDto
{
public string? Outer { get; set; }
public TestNestedChildDto? Child { get; set; }
}
private class TestNestedChildDto
{
public IRemoteStreamContent? File { get; set; }
}
private class TestThreeLevelDto
{
public string? Outer { get; set; }
public TestThreeLevelMiddleDto? Inner { get; set; }
}
private class TestThreeLevelMiddleDto
{
public TestNestedChildDto? Child { get; set; }
}
private class TestMixedUploadDto
{
public string? Label { get; set; }
public IRemoteStreamContent? Main { get; set; }
public IEnumerable<IRemoteStreamContent>? Extras { get; set; }
}
private class TestUploadDtoBase
{
public string? Name { get; set; }
public IRemoteStreamContent? File { get; set; }
}
private class TestInheritedUploadDto : TestUploadDtoBase
{
public string? ChildOnly { get; set; }
}
private record TestRecordUploadDto(string Name, IRemoteStreamContent File);
private class TestPrimitiveUploadDto
{
public DateTime When { get; set; }
public TestStatus Status { get; set; }
public int? Quantity { get; set; }
public IRemoteStreamContent? File { get; set; }
}
private enum TestStatus
{
Pending = 0,
Active = 1,
Done = 2,
}
private class TestPolymorphicDerivedDto : TestUploadDtoBase
{
public string? ExtraField { get; set; }
}
private class TestGenericUploadDto<T>
{
public T? Payload { get; set; }
public IRemoteStreamContent? File { get; set; }
}
private class StubJsonSerializer : IJsonSerializer
{
public string Serialize(object obj, bool camelCase = true, bool indented = false)
=> System.Text.Json.JsonSerializer.Serialize(obj);
public T Deserialize<T>(string jsonString, bool camelCase = true)
=> System.Text.Json.JsonSerializer.Deserialize<T>(jsonString)!;
public object Deserialize(Type type, string jsonString, bool camelCase = true)
=> System.Text.Json.JsonSerializer.Deserialize(jsonString, type)!;
}
private class TestClock : IClock
{
public DateTime Now => DateTime.UtcNow;
public DateTimeKind Kind => DateTimeKind.Utc;
public bool SupportsMultipleTimezone => false;
public DateTime Normalize(DateTime dateTime) => dateTime;
public DateTime ConvertToUserTime(DateTime utcDateTime) => utcDateTime;
public DateTimeOffset ConvertToUserTime(DateTimeOffset dateTimeOffset) => dateTimeOffset;
public DateTime ConvertToUtc(DateTime dateTime) => dateTime;
}
}

21
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<int> IncrementValueAsync(int value);
Task<string> GetPlainStringAsync();
Task<string> GetProducesJsonStringAsync();
Task<string> GetProducesTextStringAsync();
Task<string> GetNullStringAsync();
Task<string> GetProducesJsonNullStringAsync();
Task<string> GetEmptyStringAsync();
Task<string> GetEscapedStringAsync();
Task<IRemoteStreamContent> DownloadIconAsync();
Task<object> GetReferenceTypeObjectAsync();
Task<byte[]> GetByteArrayAsync();
Task GetException1Async();
Task GetException2Async();

84
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<IPeopleAppService>();
}
[Fact]
public async Task EchoStatusAsync_Should_Return_Plain_String_Without_Quotes()
{
var status = await _peopleAppService.EchoStatusAsync();
status.ShouldBe("Open");
status.StartsWith("\"").ShouldBeFalse();
status.EndsWith("\"").ShouldBeFalse();
}
[Fact]
public async Task EchoStatusWithProducesJsonAsync_Should_Return_Plain_String_Without_Quotes()
{
var status = await _peopleAppService.EchoStatusWithProducesJsonAsync();
status.ShouldBe("Open");
status.StartsWith("\"").ShouldBeFalse();
status.EndsWith("\"").ShouldBeFalse();
}
[Fact]
public async Task GetBinaryImageAsync_Should_Return_Real_Binary_Not_Json_Metadata()
{
using var content = await _peopleAppService.GetBinaryImageAsync();
using var ms = new MemoryStream();
await content.GetStream().CopyToAsync(ms);
var bytes = ms.ToArray();
content.FileName.ShouldBe("tiny.png");
content.ContentType.ShouldStartWith("image/png");
bytes.Length.ShouldBeGreaterThan(8);
bytes[0].ShouldBe((byte)0x89);
bytes[1].ShouldBe((byte)0x50);
bytes[2].ShouldBe((byte)0x4E);
bytes[3].ShouldBe((byte)0x47);
}
[Fact]
public async Task ThrowFromStringAsync_Should_Surface_Server_Exception_To_Client()
{
await Should.ThrowAsync<AbpRemoteCallException>(
() => _peopleAppService.ThrowFromStringAsync()
);
}
[Fact]
public async Task DownloadAsync_Should_Still_Work()
{
using var content = await _peopleAppService.DownloadAsync();
using var reader = new StreamReader(content.GetStream());
var text = await reader.ReadToEndAsync();
text.ShouldBe("DownloadAsync");
content.FileName.ShouldBe("download.rtf");
content.ContentType.ShouldStartWith("application/rtf");
}
[Fact]
public async Task UploadAsync_String_Return_Should_Stay_Unquoted()
{
using var ms = new MemoryStream(System.Text.Encoding.UTF8.GetBytes("hello"));
var result = await _peopleAppService.UploadAsync(
new Content.RemoteStreamContent(ms, "upload.txt", "text/plain"));
result.ShouldBe("hello:text/plain:upload.txt");
result.StartsWith("\"").ShouldBeFalse();
}
}

79
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 class RegularTestController : AbpController, IRegularTestController
return Task.FromResult(value + 1);
}
[HttpGet]
[Route("plain-string")]
public Task<string> GetPlainStringAsync()
{
return Task.FromResult("Open");
}
[HttpGet]
[Route("produces-json-string")]
[Produces("application/json")]
public Task<string> GetProducesJsonStringAsync()
{
return Task.FromResult("Open");
}
[HttpGet]
[Route("produces-text-string")]
[Produces("text/plain")]
public Task<string> GetProducesTextStringAsync()
{
return Task.FromResult("Open");
}
[HttpGet]
[Route("null-string")]
public Task<string> GetNullStringAsync()
{
return Task.FromResult<string>(null!);
}
[HttpGet]
[Route("produces-json-null-string")]
[Produces("application/json")]
public Task<string> GetProducesJsonNullStringAsync()
{
return Task.FromResult<string>(null!);
}
[HttpGet]
[Route("empty-string")]
public Task<string> GetEmptyStringAsync()
{
return Task.FromResult(string.Empty);
}
[HttpGet]
[Route("escaped-string")]
[Produces("application/json")]
public Task<string> GetEscapedStringAsync()
{
return Task.FromResult("a\"b\\c\nd");
}
[HttpGet]
[Route("download-icon")]
public Task<IRemoteStreamContent> DownloadIconAsync()
{
var bytes = Encoding.UTF8.GetBytes("ICON-BYTES");
return Task.FromResult<IRemoteStreamContent>(
new RemoteStreamContent(new MemoryStream(bytes), "icon.bin", "application/octet-stream"));
}
[HttpGet]
[Route("reference-type-object")]
public Task<object> GetReferenceTypeObjectAsync()
{
return Task.FromResult<object>(new Car { Year = 1999, Model = "BMW" });
}
[HttpGet]
[Route("byte-array")]
public Task<byte[]> GetByteArrayAsync()
{
return Task.FromResult(new byte[] { 1, 2, 3, 4 });
}
[HttpGet]
[Route("get-exception1")]
public Task GetException1Async()

103
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<IRegularTestController>();
}
[Fact]
public async Task GetPlainStringAsync_Should_Return_Unwrapped_String()
{
var result = await _controller.GetPlainStringAsync();
result.ShouldBe("Open");
result.StartsWith("\"").ShouldBeFalse();
}
[Fact]
public async Task GetProducesJsonStringAsync_Should_Strip_Json_Quotes()
{
var result = await _controller.GetProducesJsonStringAsync();
result.ShouldBe("Open");
result.StartsWith("\"").ShouldBeFalse();
result.EndsWith("\"").ShouldBeFalse();
}
[Fact]
public async Task GetProducesTextStringAsync_Should_Return_Raw_Text_Body()
{
var result = await _controller.GetProducesTextStringAsync();
result.ShouldBe("Open");
result.StartsWith("\"").ShouldBeFalse();
}
[Fact]
public async Task GetNullStringAsync_Should_Return_Default_Or_Empty()
{
var result = await _controller.GetNullStringAsync();
(result == null || result == string.Empty).ShouldBeTrue();
}
[Fact]
public async Task GetEmptyStringAsync_Should_Return_Empty()
{
var result = await _controller.GetEmptyStringAsync();
(result == null || result == string.Empty).ShouldBeTrue();
}
[Fact]
public async Task GetProducesJsonNullStringAsync_Should_Not_Return_Literal_Null()
{
var result = await _controller.GetProducesJsonNullStringAsync();
result.ShouldNotBe("null");
(result == null || result == string.Empty).ShouldBeTrue();
}
[Fact]
public async Task GetEscapedStringAsync_Should_Decode_Escaped_Characters()
{
var result = await _controller.GetEscapedStringAsync();
result.ShouldBe("a\"b\\c\nd");
}
[Fact]
public async Task DownloadIconAsync_Should_Return_Binary_Bytes()
{
using var content = await _controller.DownloadIconAsync();
using var ms = new MemoryStream();
await content.GetStream().CopyToAsync(ms);
ms.ToArray().ShouldBe(System.Text.Encoding.UTF8.GetBytes("ICON-BYTES"));
content.FileName.ShouldBe("icon.bin");
}
[Fact]
public async Task GetReferenceTypeObjectAsync_Should_Not_Be_Wrapped_As_RemoteStreamContent()
{
var result = await _controller.GetReferenceTypeObjectAsync();
result.ShouldNotBeNull();
result.ShouldNotBeAssignableTo<Volo.Abp.Content.IRemoteStreamContent>();
}
[Fact]
public async Task GetByteArrayAsync_Should_Round_Trip_Bytes()
{
var bytes = await _controller.GetByteArrayAsync();
bytes.ShouldBe(new byte[] { 1, 2, 3, 4 });
}
[Fact]
public async Task Existing_IncrementValueAsync_Regression_Should_Still_Work()
{
var result = await _controller.IncrementValueAsync(41);
result.ShouldBe(42);
}
}

279
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<IRemoteStreamContent>));
model.IsRemoteStream.ShouldBeTrue();
}
[Fact]
public void Task_Of_Custom_Stream_Subclass_Should_Be_False_After_UnwrapTask()
{
var model = ReturnValueApiDescriptionModel.Create(typeof(Task<MyCustomStreamContent>));
model.IsRemoteStream.ShouldBeFalse();
}
[Fact]
public void IRemoteStreamContent_Array_Should_Be_False()
{
var model = ReturnValueApiDescriptionModel.Create(typeof(IRemoteStreamContent[]));
model.IsRemoteStream.ShouldBeFalse();
}
[Fact]
public void Concrete_RemoteStreamContent_Array_Should_Be_False()
{
var model = ReturnValueApiDescriptionModel.Create(typeof(RemoteStreamContent[]));
model.IsRemoteStream.ShouldBeFalse();
}
[Fact]
public void List_Of_IRemoteStreamContent_Should_Be_False()
{
var model = ReturnValueApiDescriptionModel.Create(typeof(List<IRemoteStreamContent>));
model.IsRemoteStream.ShouldBeFalse();
}
[Fact]
public void IEnumerable_Of_IRemoteStreamContent_Should_Be_False()
{
var model = ReturnValueApiDescriptionModel.Create(typeof(IEnumerable<IRemoteStreamContent>));
model.IsRemoteStream.ShouldBeFalse();
}
[Fact]
public void IReadOnlyCollection_Of_IRemoteStreamContent_Should_Be_False()
{
var model = ReturnValueApiDescriptionModel.Create(typeof(IReadOnlyCollection<IRemoteStreamContent>));
model.IsRemoteStream.ShouldBeFalse();
}
[Fact]
public void Plain_String_Should_Be_False()
{
var model = ReturnValueApiDescriptionModel.Create(typeof(string));
model.IsRemoteStream.ShouldBeFalse();
}
[Fact]
public void Int_Should_Be_False()
{
var model = ReturnValueApiDescriptionModel.Create(typeof(int));
model.IsRemoteStream.ShouldBeFalse();
}
[Fact]
public void Plain_Dto_Should_Be_False()
{
var model = ReturnValueApiDescriptionModel.Create(typeof(PlainDto));
model.IsRemoteStream.ShouldBeFalse();
}
[Fact]
public void Dto_Containing_IRemoteStreamContent_Property_Should_Be_False()
{
var model = ReturnValueApiDescriptionModel.Create(typeof(DtoWithStream));
model.IsRemoteStream.ShouldBeFalse();
}
[Fact]
public void Dto_Inheriting_From_Type_With_Stream_Should_Be_False()
{
var model = ReturnValueApiDescriptionModel.Create(typeof(DtoInheritingStream));
model.IsRemoteStream.ShouldBeFalse();
}
[Fact]
public void Byte_Array_Should_Be_False()
{
var model = ReturnValueApiDescriptionModel.Create(typeof(byte[]));
model.IsRemoteStream.ShouldBeFalse();
}
[Fact]
public void Dictionary_Should_Be_False()
{
var model = ReturnValueApiDescriptionModel.Create(typeof(Dictionary<string, int>));
model.IsRemoteStream.ShouldBeFalse();
}
private class MyCustomStreamContent : IRemoteStreamContent
{
public string? FileName => null;
public string? ContentType => null;
public long? ContentLength => null;
public Stream GetStream() => Stream.Null;
public void Dispose() { }
}
private class PlainDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
private class DtoWithStream
{
public string FileName { get; set; } = string.Empty;
public IRemoteStreamContent? File { get; set; }
}
private class DtoInheritingStream : PlainDto
{
public IRemoteStreamContent? Stream { get; set; }
}
}
public class ReturnValueApiDescriptionModel_BackwardsCompat_Tests
{
[Fact]
public void Deserializing_Json_Without_ContentTypes_Field_Should_Leave_It_Null()
{
var json = """
{
"type": "System.String",
"typeSimple": "string"
}
""";
var model = System.Text.Json.JsonSerializer.Deserialize<ReturnValueApiDescriptionModel>(
json,
new System.Text.Json.JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
model.ShouldNotBeNull();
model!.Type.ShouldBe("System.String");
model.TypeSimple.ShouldBe("string");
model.ContentTypes.ShouldBeNull();
}
[Fact]
public void Deserializing_Json_With_ContentTypes_Field_Should_Populate_It()
{
var json = """
{
"type": "System.String",
"typeSimple": "string",
"contentTypes": ["application/json", "text/plain"]
}
""";
var model = System.Text.Json.JsonSerializer.Deserialize<ReturnValueApiDescriptionModel>(
json,
new System.Text.Json.JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
model!.ContentTypes.ShouldNotBeNull();
model.ContentTypes!.ShouldBe(new[] { "application/json", "text/plain" });
}
[Fact]
public void Serializing_With_Null_ContentTypes_Should_Emit_Null_Or_Omit()
{
var model = ReturnValueApiDescriptionModel.Create(typeof(string));
var json = System.Text.Json.JsonSerializer.Serialize(model);
var deserialized = System.Text.Json.JsonSerializer.Deserialize<ReturnValueApiDescriptionModel>(
json,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true });
deserialized!.ContentTypes.ShouldBeNull();
}
}
public class ActionApiDescriptionModel_Tests
{
[Fact]
public void Create_Should_Propagate_ReturnValueContentTypes()
{
var method = typeof(ActionApiDescriptionModel_Tests).GetMethod(nameof(SampleMethod))!;
var model = ActionApiDescriptionModel.Create(
uniqueName: "SampleMethod",
method: method,
url: "api/test/sample",
httpMethod: "GET",
supportedVersions: new[] { "1.0" },
allowAnonymous: true,
authorizeDatas: null,
implementFrom: null,
returnValueContentTypes: new[] { "application/octet-stream" });
model.ReturnValue.ContentTypes.ShouldNotBeNull();
model.ReturnValue.ContentTypes!.ShouldBe(new[] { "application/octet-stream" });
}
[Fact]
public void Create_Without_ReturnValueContentTypes_Should_Leave_Null()
{
var method = typeof(ActionApiDescriptionModel_Tests).GetMethod(nameof(SampleMethod))!;
var model = ActionApiDescriptionModel.Create(
uniqueName: "SampleMethod",
method: method,
url: "api/test/sample",
httpMethod: "GET",
supportedVersions: new[] { "1.0" });
model.ReturnValue.ContentTypes.ShouldBeNull();
}
public string SampleMethod() => string.Empty;
}

266
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<string>? contentTypes, bool isRemoteStream = false)
{
var model = ApplicationApiDescriptionModel.Create();
var module = model.GetOrAddModule("app", "Default");
var controller = module.GetOrAddController(
name: "TestController",
groupName: null,
isRemoteService: true,
isIntegrationService: false,
apiVersion: null,
type: typeof(object));
var action = new ActionApiDescriptionModel
{
UniqueName = "DoSomethingAsync",
Name = "DoSomethingAsync",
HttpMethod = "GET",
Url = "api/test/do-something",
SupportedVersions = new List<string>(),
ParametersOnMethod = new List<MethodParameterApiDescriptionModel>(),
Parameters = new List<ParameterApiDescriptionModel>(),
ReturnValue = new ReturnValueApiDescriptionModel
{
Type = returnType,
TypeSimple = returnType,
ContentTypes = contentTypes,
IsRemoteStream = isRemoteStream,
},
AuthorizeDatas = new List<AuthorizeDataApiDescriptionModel>(),
};
controller.AddAction("DoSomethingAsync", action);
return model;
}
private static ApplicationApiDescriptionModel BuildUploadModel(
(string Name, string NameOnMethod, string BindingSourceId)[] uploadParameters)
{
var model = ApplicationApiDescriptionModel.Create();
var module = model.GetOrAddModule("app", "Default");
var controller = module.GetOrAddController(
name: "TestController",
groupName: null,
isRemoteService: true,
isIntegrationService: false,
apiVersion: null,
type: typeof(object));
var parameters = new List<ParameterApiDescriptionModel>();
foreach (var (name, nameOnMethod, binding) in uploadParameters)
{
parameters.Add(new ParameterApiDescriptionModel
{
Name = name,
NameOnMethod = nameOnMethod,
Type = "System.String",
TypeSimple = "string",
BindingSourceId = binding,
});
}
var action = new ActionApiDescriptionModel
{
UniqueName = "UploadAsync",
Name = "UploadAsync",
HttpMethod = "POST",
Url = "api/test/upload",
SupportedVersions = new List<string>(),
ParametersOnMethod = new List<MethodParameterApiDescriptionModel>(),
Parameters = parameters,
ReturnValue = new ReturnValueApiDescriptionModel
{
Type = "System.Void",
TypeSimple = "void",
},
AuthorizeDatas = new List<AuthorizeDataApiDescriptionModel>(),
};
controller.AddAction("UploadAsync", action);
return model;
}
}

8
framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/IPeopleAppService.cs

@ -39,4 +39,12 @@ public interface IPeopleAppService : ICrudAppService<PersonDto, Guid>
Task<string> GetParamsFromQueryAsync(GetParamsInput input);
Task<string> GetParamsFromFormAsync(GetParamsInput input);
Task<string> EchoStatusAsync();
Task<string> EchoStatusWithProducesJsonAsync();
Task<IRemoteStreamContent> GetBinaryImageAsync();
Task<string> ThrowFromStringAsync();
}

25
framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Application/PeopleAppService.cs

@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Authorization;
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 class PeopleAppService : CrudAppService<Person, PersonDto, Guid>, IPeople
return new RemoteStreamContent(memoryStream, "download.rtf", "application/rtf");
}
public Task<string> EchoStatusAsync()
{
return Task.FromResult("Open");
}
[Produces("application/json")]
public Task<string> EchoStatusWithProducesJsonAsync()
{
return Task.FromResult("Open");
}
public Task<IRemoteStreamContent> GetBinaryImageAsync()
{
var bytes = Convert.FromBase64String(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=");
return Task.FromResult<IRemoteStreamContent>(
new RemoteStreamContent(new MemoryStream(bytes), "tiny.png", "image/png"));
}
public Task<string> ThrowFromStringAsync()
{
throw new BusinessException("TestApp.StringEndpointBoom", "string endpoint failed");
}
public async Task<string> UploadAsync(IRemoteStreamContent streamContent)
{
using (var reader = new StreamReader(streamContent.GetStream()))

109
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<T>).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<unknown>['headers'],
responseType: Rest.ResponseType,
): Rest.Request<unknown>['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<unknown>['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<any> {
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<string> {
if (typeof (blob as any).text === 'function') {
return (blob as any).text();
}
return new Promise<string>((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;

362
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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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 } })

10
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 %>,<% } %><%

1
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',
}

7
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;

5
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);
}

846
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>): Action {
return {
uniqueName: 'GetStatusAsync',
name: 'GetStatus',
httpMethod: 'GET',
url: 'api/app/test-service/status',
supportedVersions: [],
parametersOnMethod: [],
parameters: [],
returnValue: { type: 'System.String', typeSimple: 'string' },
...overrides,
} as Action;
}
describe('createActionToBodyMapper — string return value', () => {
const mapBody = createActionToBodyMapper();
test('without contentTypes falls back to text mode (legacy behavior)', () => {
const body = mapBody(buildAction({}));
expect(body.responseType).toBe('string');
expect(body.httpResponseType).toBe('text');
expect(body.acceptHeader).toBeUndefined();
});
test('with contentTypes containing application/json picks json + Accept: application/json', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.String',
typeSimple: 'string',
contentTypes: ['application/json', 'text/plain'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('json');
expect(body.acceptHeader).toBe('application/json');
});
test('with only text/* contentTypes picks text + Accept: text/plain', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.String',
typeSimple: 'string',
contentTypes: ['text/plain', 'text/csv'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('text');
expect(body.acceptHeader).toBe('text/plain');
});
});
describe('createActionToBodyMapper — IRemoteStreamContent return value', () => {
const mapBody = createActionToBodyMapper();
test('always picks blob + Accept: application/octet-stream regardless of contentTypes', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'Volo.Abp.Content.IRemoteStreamContent',
typeSimple: 'Volo.Abp.Content.IRemoteStreamContent',
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('blob');
expect(body.acceptHeader).toBe('application/octet-stream');
expect(body.isBlobMethod()).toBe(true);
});
test('binary-only contentTypes picks blob and echoes back the actual binary media type', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.Byte[]',
typeSimple: 'byte[]',
contentTypes: ['application/pdf'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('blob');
expect(body.acceptHeader).toBe('application/pdf');
});
});
describe('createActionToBodyMapper — other return values', () => {
const mapBody = createActionToBodyMapper();
test('object return without contentTypes has no httpResponseType / acceptHeader (defaults to json)', () => {
const body = mapBody(
buildAction({
returnValue: { type: 'My.Project.UserDto', typeSimple: 'My.Project.UserDto' },
} as Partial<Action>),
);
expect(body.httpResponseType).toBeUndefined();
expect(body.acceptHeader).toBeUndefined();
});
test('void return has no httpResponseType / acceptHeader', () => {
const body = mapBody(
buildAction({
returnValue: { type: 'System.Void', typeSimple: 'void' },
} as Partial<Action>),
);
expect(body.httpResponseType).toBeUndefined();
expect(body.acceptHeader).toBeUndefined();
});
test('registers a query parameter via the binding source', () => {
const body = mapBody(
buildAction({
parameters: [
{
nameOnMethod: 'id',
name: 'id',
jsonName: null,
type: 'System.Guid',
typeSimple: 'string',
isOptional: false,
defaultValue: null,
constraintTypes: null,
bindingSourceId: eBindingSourceId.Query,
descriptorName: '',
},
],
}),
);
expect(body.params).toEqual(['id']);
});
});
describe('createActionToBodyMapper — IsRemoteStream backend flag', () => {
const mapBody = createActionToBodyMapper();
test('isRemoteStream=true forces blob even if Type is a custom subclass name', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'My.Project.CustomStreamContent',
typeSimple: 'My.Project.CustomStreamContent',
isRemoteStream: true,
contentTypes: ['text/plain', 'application/json'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('blob');
expect(body.acceptHeader).toBe('application/octet-stream');
expect(body.isBlobMethod()).toBe(true);
});
test('[Volo.Abp.Content.IRemoteStreamContent] (real ABP square-bracket form) must NOT pick blob and degrades responseType to any[]', () => {
// ABP serialises collections as `[T]` (not `T[]`) — pin the on-the-wire shape.
const body = mapBody(
buildAction({
returnValue: {
type: 'System.Collections.Generic.IList<Volo.Abp.Content.IRemoteStreamContent>',
typeSimple: '[Volo.Abp.Content.IRemoteStreamContent]',
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBeUndefined();
expect(body.acceptHeader).toBeUndefined();
expect(body.isBlobMethod()).toBe(false);
expect(body.responseType).toBe('any[]');
});
test('IRemoteStreamContent[] array return must NOT pick blob (server falls back to JSON metadata)', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'Volo.Abp.Content.IRemoteStreamContent[]',
typeSimple: 'Volo.Abp.Content.IRemoteStreamContent[]',
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBeUndefined();
expect(body.acceptHeader).toBeUndefined();
expect(body.isBlobMethod()).toBe(false);
expect(body.responseType).toBe('any[]');
});
test('isRemoteStream=false with stream-content type-name still detected by type name (legacy)', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'Volo.Abp.Content.IRemoteStreamContent',
typeSimple: 'Volo.Abp.Content.IRemoteStreamContent',
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('blob');
expect(body.acceptHeader).toBe('application/octet-stream');
});
});
describe('createActionToBodyMapper — +json suffix detection', () => {
const mapBody = createActionToBodyMapper();
test('application/problem+json echoes back as Accept (decoder stays json)', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.String',
typeSimple: 'string',
contentTypes: ['application/problem+json'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('json');
expect(body.acceptHeader).toBe('application/problem+json');
});
test('text/json echoes back as text/json (decoder stays json)', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.String',
typeSimple: 'string',
contentTypes: ['text/json'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('json');
expect(body.acceptHeader).toBe('text/json');
});
test('application/vnd.api+json echoes back as Accept (decoder stays json)', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.String',
typeSimple: 'string',
contentTypes: ['application/vnd.api+json'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('json');
expect(body.acceptHeader).toBe('application/vnd.api+json');
});
});
describe('createActionToBodyMapper — expanded binary whitelist', () => {
const mapBody = createActionToBodyMapper();
test.each([
['application/wasm'],
['font/woff2'],
['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
['application/vnd.ms-excel'],
['application/vnd.oasis.opendocument.spreadsheet'],
['application/x-msdownload'],
['application/rtf'],
['application/x-rar-compressed'],
['application/x-bzip2'],
['application/x-iso9660-image'],
['application/java-archive'],
['application/epub+zip'],
['model/gltf-binary'],
])('"%s" picked as blob', mediaType => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.Byte[]',
typeSimple: 'byte[]',
contentTypes: [mediaType],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('blob');
});
});
describe('createActionToBodyMapper — contentTypes precedence and edge cases', () => {
const mapBody = createActionToBodyMapper();
test('isBlobMethod() type detection wins over json in contentTypes', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'Volo.Abp.Content.RemoteStreamContent',
typeSimple: 'Volo.Abp.Content.RemoteStreamContent',
contentTypes: ['application/json', 'text/plain'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('blob');
expect(body.acceptHeader).toBe('application/octet-stream');
});
test('IRemoteStreamContent[] array falls through to defaults (server returns JSON metadata, not binary)', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'Volo.Abp.Content.IRemoteStreamContent[]',
typeSimple: 'Volo.Abp.Content.IRemoteStreamContent[]',
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBeUndefined();
expect(body.acceptHeader).toBeUndefined();
});
test('case-insensitive json detection (APPLICATION/JSON)', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.String',
typeSimple: 'string',
contentTypes: ['APPLICATION/JSON'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('json');
expect(body.acceptHeader).toBe('application/json');
});
test('image/* contentTypes alone picks blob and echoes back the first image type', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.Byte[]',
typeSimple: 'byte[]',
contentTypes: ['image/png', 'image/jpeg'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('blob');
expect(body.acceptHeader).toBe('image/png');
});
test('video/* and audio/* picked as blob', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.Byte[]',
typeSimple: 'byte[]',
contentTypes: ['video/mp4', 'audio/mpeg'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('blob');
});
test('application/pdf contentTypes picked as blob', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.Byte[]',
typeSimple: 'byte[]',
contentTypes: ['application/pdf'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('blob');
});
test('empty contentTypes falls through to legacy string→text behavior', () => {
const body = mapBody(
buildAction({
returnValue: { type: 'System.String', typeSimple: 'string', contentTypes: [] },
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('text');
expect(body.acceptHeader).toBeUndefined();
});
test('mixed text/* and application/json picks json and echoes the first json-shaped media type', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.String',
typeSimple: 'string',
contentTypes: ['text/json', 'text/plain', 'application/json'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('json');
expect(body.acceptHeader).toBe('text/json');
});
test('contentTypes with json-suffix variants still picks json', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.String',
typeSimple: 'string',
contentTypes: ['application/json; charset=utf-8'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('json');
});
test('non-string non-blob type with contentTypes containing json defaults appropriately', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'My.Project.UserDto',
typeSimple: 'My.Project.UserDto',
contentTypes: ['application/json'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('json');
expect(body.acceptHeader).toBe('application/json');
});
test('json contentType with charset parameter is normalized', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.String',
typeSimple: 'string',
contentTypes: ['application/json; charset=utf-8'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('json');
expect(body.acceptHeader).toBe('application/json');
});
test('text contentType with charset parameter is normalized', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.String',
typeSimple: 'string',
contentTypes: ['text/plain ; charset=utf-8 '],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('text');
expect(body.acceptHeader).toBe('text/plain');
});
test('mixed text/plain (with charset) and application/json picks json after normalize', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.String',
typeSimple: 'string',
contentTypes: ['text/plain; charset=utf-8', 'application/json; charset=utf-8'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('json');
});
test('text/csv only (custom text format) picks text and echoes the content type back', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'System.String',
typeSimple: 'string',
contentTypes: ['text/csv'],
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('text');
expect(body.acceptHeader).toBe('text/csv');
});
});
describe('createActionToBodyMapper — backward compatibility', () => {
const mapBody = createActionToBodyMapper();
test('legacy api-definition without contentTypes works (string)', () => {
const body = mapBody(
buildAction({
returnValue: { type: 'System.String', typeSimple: 'string' } as Partial<Action>['returnValue'],
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('text');
expect(body.acceptHeader).toBeUndefined();
});
test('legacy api-definition without contentTypes works (IRemoteStreamContent)', () => {
const body = mapBody(
buildAction({
returnValue: {
type: 'Volo.Abp.Content.IRemoteStreamContent',
typeSimple: 'Volo.Abp.Content.IRemoteStreamContent',
},
} as Partial<Action>),
);
expect(body.httpResponseType).toBe('blob');
expect(body.acceptHeader).toBe('application/octet-stream');
});
test('legacy api-definition without contentTypes works (object)', () => {
const body = mapBody(
buildAction({
returnValue: { type: 'My.Project.UserDto', typeSimple: 'My.Project.UserDto' },
} as Partial<Action>),
);
expect(body.httpResponseType).toBeUndefined();
expect(body.acceptHeader).toBeUndefined();
});
test('void / no return preserves legacy behavior', () => {
const body = mapBody(
buildAction({
returnValue: { type: 'System.Void', typeSimple: 'void' },
} as Partial<Action>),
);
expect(body.httpResponseType).toBeUndefined();
expect(body.acceptHeader).toBeUndefined();
});
});
describe('proxy service template emission', () => {
const template = readFileSync(TEMPLATE_PATH, 'utf8');
test('reads body.httpResponseType and body.acceptHeader from body', () => {
expect(template).toContain('body.httpResponseType');
expect(template).toContain('body.acceptHeader');
});
test('emits Accept header conditional', () => {
expect(template).toMatch(/headers:\s*\{\s*Accept:/);
});
test('emits responseType only for non-json httpResponseType', () => {
expect(template).toMatch(/httpResponseType\s*&&\s*httpResponseType\s*!==\s*'json'/);
});
});
describe('createActionToBodyMapper — multipart FormData uploads', () => {
const mapBody = createActionToBodyMapper();
test('DTO with one IRemoteStreamContent property collapses to FormData body using the method arg name', () => {
const body = mapBody(
buildAction({
httpMethod: 'POST',
parameters: [
{ name: 'Name', nameOnMethod: 'input', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Form } as any,
{ name: 'File', nameOnMethod: 'input', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any,
],
} as Partial<Action>),
);
expect(body.body).toBe('input');
expect(body.params).toEqual([]);
});
test('DTO with IEnumerable<IRemoteStreamContent> collapses to FormData body using the method arg name', () => {
const body = mapBody(
buildAction({
httpMethod: 'POST',
parameters: [
{ name: 'Label', nameOnMethod: 'input', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Form } as any,
{ name: 'Files', nameOnMethod: 'input', type: 'System.Collections.Generic.IEnumerable<Volo.Abp.Content.IRemoteStreamContent>', typeSimple: '[Volo.Abp.Content.IRemoteStreamContent]', bindingSourceId: eBindingSourceId.FormFile } as any,
],
} as Partial<Action>),
);
expect(body.body).toBe('input');
expect(body.params).toEqual([]);
});
test('nested DTO with IRemoteStreamContent collapses to FormData body using the method arg name', () => {
const body = mapBody(
buildAction({
httpMethod: 'POST',
parameters: [
{ name: 'Outer', nameOnMethod: 'input', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Form } as any,
{ name: 'Child.File', nameOnMethod: 'input', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any,
],
} as Partial<Action>),
);
expect(body.body).toBe('input');
expect(body.params).toEqual([]);
});
test('direct IRemoteStreamContent parameter uses its own method arg name as the FormData body', () => {
const body = mapBody(
buildAction({
httpMethod: 'POST',
parameters: [
{ name: 'file', nameOnMethod: 'file', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any,
],
} as Partial<Action>),
);
expect(body.body).toBe('file');
});
test('Path + upload-DTO mix keeps the path parameter while binding the upload to FormData', () => {
const body = mapBody(
buildAction({
httpMethod: 'POST',
url: 'api/upload/{id}',
parameters: [
{ name: 'id', nameOnMethod: 'id', type: 'System.Int32', typeSimple: 'number', bindingSourceId: eBindingSourceId.Path } as any,
{ name: 'Name', nameOnMethod: 'input', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Form } as any,
{ name: 'File', nameOnMethod: 'input', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any,
],
} as Partial<Action>),
);
expect(body.body).toBe('input');
expect(body.url).toBe("`/api/upload/${id}`");
});
test('actions without FormFile parameters are not affected', () => {
const body = mapBody(
buildAction({
httpMethod: 'POST',
parameters: [
{ name: 'Name', nameOnMethod: 'name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Body } as any,
],
} as Partial<Action>),
);
expect(body.body).toBe('name');
});
});
describe('createActionToBodyMapper — multipart upload params regression', () => {
const mapBody = createActionToBodyMapper();
test('AppService convention ModelBinding non-file field sharing the upload arg is dropped from query params', () => {
const body = mapBody(
buildAction({
httpMethod: 'POST',
url: 'api/app/proxy-demo-test/upload-single',
parameters: [
{ nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any,
{ nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any,
],
} as Partial<Action>),
);
expect(body.body).toBe('input');
expect(body.params.join(',')).not.toContain('name');
});
test('Path param on a separate method arg stays in URL even when sibling form fields share the upload arg', () => {
const body = mapBody(
buildAction({
httpMethod: 'POST',
url: 'api/proxy-demo/media/upload-with-path/{id}',
parameters: [
{ nameOnMethod: 'id', name: 'id', type: 'System.Int32', typeSimple: 'int', bindingSourceId: eBindingSourceId.Path } as any,
{ nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any,
{ nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any,
],
} as Partial<Action>),
);
expect(body.body).toBe('input');
expect(body.url).toContain('${id}');
expect(body.params.join(',')).not.toContain('name');
});
test('Multiple direct FormFile method args: body forwards only the first var (known limitation, mirrors jQuery)', () => {
const body = mapBody(
buildAction({
httpMethod: 'POST',
url: 'api/proxy-demo/media/upload-two-direct',
parameters: [
{ nameOnMethod: 'file1', name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any,
{ nameOnMethod: 'file2', name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any,
],
} as Partial<Action>),
);
expect(body.body).toBe('file1');
});
});
describe('createActionToSignatureMapper — multipart upload signature collapse', () => {
const mapSignature = createActionToSignatureMapper();
test('FormFile DTO method arg collapses to FormData type', () => {
const sig = mapSignature(buildAction({
httpMethod: 'POST',
url: 'api/app/proxy-demo-test/upload-single',
parametersOnMethod: [
{ name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any,
],
parameters: [
{ nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any,
{ nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any,
],
} as Partial<Action>));
const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`);
expect(types).toContain('input:FormData');
expect(types[types.length - 1]).toBe('config:Partial<Rest.Config>');
});
test('Path arg keeps its primitive type while upload arg becomes FormData', () => {
const sig = mapSignature(buildAction({
httpMethod: 'POST',
url: 'api/proxy-demo/media/upload-with-path/{id}',
parametersOnMethod: [
{ name: 'id', type: 'System.Int32', typeAsString: 'System.Int32', typeSimple: 'number', isOptional: false, defaultValue: null } as any,
{ name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any,
],
parameters: [
{ nameOnMethod: 'id', name: 'id', type: 'System.Int32', typeSimple: 'int', bindingSourceId: eBindingSourceId.Path } as any,
{ nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any,
{ nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any,
],
} as Partial<Action>));
const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`);
expect(types).toEqual([
'id:number',
'input:FormData',
'config:Partial<Rest.Config>',
]);
});
test('Multiple direct IRemoteStreamContent method args each become FormData independently', () => {
const sig = mapSignature(buildAction({
httpMethod: 'POST',
url: 'api/proxy-demo/media/upload-two-direct',
parametersOnMethod: [
{ name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeAsString: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', isOptional: false, defaultValue: null } as any,
{ name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeAsString: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', isOptional: false, defaultValue: null } as any,
],
parameters: [
{ nameOnMethod: 'file1', name: 'file1', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any,
{ nameOnMethod: 'file2', name: 'file2', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any,
],
} as Partial<Action>));
const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`);
expect(types).toEqual([
'file1:FormData',
'file2:FormData',
'config:Partial<Rest.Config>',
]);
});
test('Non-upload action signature stays untouched (regression guard)', () => {
const sig = mapSignature(buildAction({
httpMethod: 'GET',
url: 'api/app/proxy-demo-test/get-item-by-id',
parametersOnMethod: [
{ name: 'id', type: 'System.Int32', typeAsString: 'System.Int32', typeSimple: 'number', isOptional: false, defaultValue: null } as any,
],
parameters: [
{ nameOnMethod: 'id', name: 'id', type: 'System.Int32', typeSimple: 'int', bindingSourceId: eBindingSourceId.Path } as any,
],
} as Partial<Action>));
const types = sig.parameters.map((p: any) => `${p.name}:${p.type}`);
expect(types).toEqual(['id:number', 'config:Partial<Rest.Config>']);
});
});
describe('createActionToMethodMapper — signature + body wired together for upload actions', () => {
const mapMethod = createActionToMethodMapper();
test('Upload action produces both FormData signature and FormData body in one pass', () => {
const method = mapMethod(buildAction({
httpMethod: 'POST',
url: 'api/app/proxy-demo-test/upload-single',
parametersOnMethod: [
{ name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any,
],
parameters: [
{ nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any,
{ nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any,
],
} as Partial<Action>));
const sigTypes = method.signature.parameters.map((p: any) => `${p.name}:${p.type}`);
expect(sigTypes).toContain('input:FormData');
expect(method.body.body).toBe('input');
expect(method.body.params.join(',')).not.toContain('name');
});
test('Query + ModelBinding + FormFile mix preserves Query as URL param and drops upload-arg fields', () => {
const method = mapMethod(buildAction({
httpMethod: 'POST',
url: 'api/proxy-demo/media/upload-with-query',
parametersOnMethod: [
{ name: 'tag', type: 'System.String', typeAsString: 'System.String', typeSimple: 'string', isOptional: false, defaultValue: null } as any,
{ name: 'input', type: 'AbpProxyDemo.UploadDto', typeAsString: 'AbpProxyDemo.UploadDto', typeSimple: 'AbpProxyDemo.UploadDto', isOptional: false, defaultValue: null } as any,
],
parameters: [
{ nameOnMethod: 'tag', name: 'tag', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.Query } as any,
{ nameOnMethod: 'input', name: 'Name', type: 'System.String', typeSimple: 'string', bindingSourceId: eBindingSourceId.ModelBinding } as any,
{ nameOnMethod: 'input', name: 'File', type: 'Volo.Abp.Content.IRemoteStreamContent', typeSimple: 'Volo.Abp.Content.IRemoteStreamContent', bindingSourceId: eBindingSourceId.FormFile } as any,
],
} as Partial<Action>));
const sigTypes = method.signature.parameters.map((p: any) => `${p.name}:${p.type}`);
expect(sigTypes).toEqual(['tag:string', 'input:FormData', 'config:Partial<Rest.Config>']);
expect(method.body.body).toBe('input');
expect(method.body.params).toContain('tag');
expect(method.body.params.join(',')).not.toContain('name');
});
});

495
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, unknown>): string {
const compiled = lodashTemplate(TEMPLATE_SRC, {
imports: {
camel: (s: string) => s.charAt(0).toLowerCase() + s.slice(1),
serializeParameters: (params: Array<{ name: string; type: string; default?: string }>) =>
params.map(p => `${p.name}: ${p.type}`).join(', '),
},
});
return compiled(context);
}
function buildContext(body: Partial<MockBody>) {
return {
apiName: 'Default',
name: 'Sample',
namespace: 'app',
imports: [
{ keyword: 'import', specifiers: ['RestService', 'Rest'], path: '@abp/ng.core' },
{ keyword: 'import', specifiers: ['Injectable', 'inject'], path: '@angular/core' },
],
methods: [
{
body: makeBody(body),
signature: {
name: 'GetSampleAsync',
parameters: [],
},
},
],
};
}
interface MockBody {
method: string;
url: string;
responseType: string;
responseTypeWithNamespace: string;
httpResponseType?: string;
acceptHeader?: string;
body?: string;
params: string[];
dictParamVar?: string;
requestType: string;
isBlobMethod(): boolean;
}
function makeBody(overrides: Partial<MockBody>): MockBody {
return {
method: 'GET',
url: "'/api/sample'",
responseType: 'any',
responseTypeWithNamespace: 'any',
httpResponseType: undefined,
acceptHeader: undefined,
body: undefined,
params: [],
dictParamVar: undefined,
requestType: 'any',
isBlobMethod: () => false,
...overrides,
};
}
describe('proxy service template — rendered output', () => {
test('default JSON body emits no responseType and no Accept header', () => {
const output = render(buildContext({
responseType: 'MyDto',
responseTypeWithNamespace: 'My.Project.MyDto',
}));
expect(output).toContain("method: 'GET'");
expect(output).not.toContain('responseType:');
expect(output).not.toContain('headers:');
});
test('json httpResponseType emits Accept but no responseType (default is json)', () => {
const output = render(buildContext({
responseType: 'string',
responseTypeWithNamespace: 'string',
httpResponseType: 'json',
acceptHeader: 'application/json',
}));
expect(output).toContain("headers: { Accept: 'application/json' }");
expect(output).not.toContain('responseType:');
});
test('text httpResponseType emits both responseType and Accept', () => {
const output = render(buildContext({
responseType: 'string',
responseTypeWithNamespace: 'string',
httpResponseType: 'text',
acceptHeader: 'text/plain',
}));
expect(output).toContain("responseType: 'text'");
expect(output).toContain("headers: { Accept: 'text/plain' }");
});
test('blob (IRemoteStreamContent) emits Blob return type + responseType + Accept', () => {
const output = render(buildContext({
responseType: 'Volo.Abp.Content.IRemoteStreamContent',
responseTypeWithNamespace: 'Volo.Abp.Content.IRemoteStreamContent',
isBlobMethod: () => true,
httpResponseType: 'blob',
acceptHeader: 'application/octet-stream',
}));
expect(output).toContain("responseType: 'blob'");
expect(output).toContain('Blob>');
expect(output).toContain("headers: { Accept: 'application/octet-stream' }");
});
test('IRemoteStreamContent[] degradation renders any[] return type and does not reference IRemoteStreamContent', () => {
const output = render(buildContext({
responseType: 'any[]',
responseTypeWithNamespace: '[Volo.Abp.Content.IRemoteStreamContent]',
isBlobMethod: () => false,
httpResponseType: undefined,
acceptHeader: undefined,
}));
expect(output).toContain('any[]');
expect(output).not.toContain('IRemoteStreamContent');
expect(output).not.toContain("responseType: 'blob'");
});
test('arraybuffer httpResponseType emits responseType', () => {
const output = render(buildContext({
responseType: 'ArrayBuffer',
responseTypeWithNamespace: 'ArrayBuffer',
httpResponseType: 'arraybuffer',
acceptHeader: 'application/octet-stream',
}));
expect(output).toContain("responseType: 'arraybuffer'");
expect(output).toContain("headers: { Accept: 'application/octet-stream' }");
});
test('no acceptHeader → no headers line', () => {
const output = render(buildContext({
responseType: 'string',
responseTypeWithNamespace: 'string',
httpResponseType: 'text',
acceptHeader: undefined,
}));
expect(output).toContain("responseType: 'text'");
expect(output).not.toContain('headers:');
});
test('rendered service code is valid TypeScript-shaped (closing braces / semicolons)', () => {
const output = render(buildContext({
responseType: 'string',
responseTypeWithNamespace: 'string',
httpResponseType: 'json',
acceptHeader: 'application/json',
}));
expect(output).toContain('@Injectable({');
expect(output).toContain('providedIn: \'root\'');
expect(output).toContain('export class SampleService');
expect(output).toContain('this.restService.request<any,');
expect(output.match(/}/g)!.length).toBeGreaterThanOrEqual(3);
});
test.each([
{ name: 'string + json Accept', body: { responseType: 'string', responseTypeWithNamespace: 'string', httpResponseType: 'json', acceptHeader: 'application/problem+json' } },
{ name: 'string + text Accept', body: { responseType: 'string', responseTypeWithNamespace: 'string', httpResponseType: 'text', acceptHeader: 'text/csv' } },
{ name: 'blob + pdf Accept', body: { responseType: 'Blob', responseTypeWithNamespace: 'Blob', httpResponseType: 'blob', acceptHeader: 'application/pdf', isBlobMethod: () => true } },
{ name: 'any[] degradation', body: { responseType: 'any[]', responseTypeWithNamespace: '[Volo.Abp.Content.IRemoteStreamContent]' } },
{ name: 'xml Accept only', body: { responseType: 'any', responseTypeWithNamespace: 'any', acceptHeader: 'application/xml' } },
])('rendered service compiles cleanly under real ts.Program ($name)', ({ body }) => {
const ts = require('typescript');
const ctx = buildContext(body as Partial<MockBody>);
ctx.methods[0].signature.parameters = [{ name: 'config', type: 'Record<string, any>' } as any];
const output = render(ctx);
const abpStub = `
declare module '@abp/ng.core' {
export namespace Rest {
export interface Config {
apiName?: string;
observe?: any;
skipHandleError?: boolean;
responseType?: string;
[key: string]: any;
}
export type Observe = any;
}
export class RestService {
request<TBody, TResponse>(req: any, config?: any): import('rxjs').Observable<TResponse>;
}
}
`;
const domStub = 'declare class Blob { constructor(parts?: any[], options?: any); }\n';
const angularCoreStub = `
declare module '@angular/core' {
export function Injectable(opts?: any): ClassDecorator;
export function inject<T>(token: { new (...args: any[]): T }): T;
export function inject<T>(token: any): T;
}
`;
const rxjsStub = `
declare module 'rxjs' {
export class Observable<T> { subscribe(...args: any[]): unknown; }
}
`;
const ambient = abpStub + angularCoreStub + rxjsStub + domStub;
const sources: Record<string, string> = {
'/proxy/sample.service.ts': output,
'/proxy/ambient.d.ts': ambient,
};
const compilerOptions: any = {
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.ES2020,
moduleResolution: ts.ModuleResolutionKind.NodeJs,
experimentalDecorators: true,
emitDecoratorMetadata: true,
strict: true,
noEmit: true,
skipLibCheck: true,
};
const baseHost = ts.createCompilerHost(compilerOptions, true);
const host: any = {
...baseHost,
getSourceFile: (fileName: string, languageVersion: any, onError: any) => {
if (sources[fileName]) {
return ts.createSourceFile(fileName, sources[fileName], languageVersion, true);
}
return baseHost.getSourceFile(fileName, languageVersion, onError);
},
fileExists: (fileName: string) =>
sources[fileName] != null || baseHost.fileExists(fileName),
readFile: (fileName: string) =>
sources[fileName] ?? baseHost.readFile(fileName),
};
const program = ts.createProgram(Object.keys(sources), compilerOptions, host);
const errors = ts
.getPreEmitDiagnostics(program)
.filter((d: any) =>
d.category === ts.DiagnosticCategory.Error &&
d.code !== 6053,
);
if (errors.length) {
const messages = errors
.map((d: any) => {
const where = d.file
? (() => {
const p = d.file.getLineAndCharacterOfPosition(d.start ?? 0);
const lineText = d.file.text.split('\n')[p.line];
return `${d.file.fileName}:${p.line + 1}:${p.character + 1}\n>>> ${lineText}\n>>> ${' '.repeat(p.character)}^`;
})()
: '(no file)';
return `[${where}] TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`;
})
.join('\n---\n');
throw new Error(`Generated proxy did not compile:\n${output}\n=== diagnostics ===\n${messages}`);
}
expect(errors).toHaveLength(0);
});
test.each([
{
name: 'DTO upload — single FormData arg',
signatureParams: [
{ name: 'input', type: 'FormData' },
{ name: 'config', type: 'Record<string, any>' },
],
bodyOverrides: { method: 'POST', url: "'/api/test/upload-single'", body: 'input' },
shouldContain: ['input: FormData', 'body: input'],
},
{
name: 'direct upload — FormData arg with custom name',
signatureParams: [
{ name: 'file', type: 'FormData' },
{ name: 'config', type: 'Record<string, any>' },
],
bodyOverrides: { method: 'POST', url: "'/api/test/upload-direct'", body: 'file' },
shouldContain: ['file: FormData', 'body: file'],
},
{
name: 'path + upload mixed — id stays in URL, FormData becomes body',
signatureParams: [
{ name: 'id', type: 'number' },
{ name: 'input', type: 'FormData' },
{ name: 'config', type: 'Record<string, any>' },
],
bodyOverrides: { method: 'POST', url: '`/api/test/upload-with-path/${id}`', body: 'input' },
shouldContain: ['id: number', 'input: FormData', 'body: input'],
},
{
name: 'query + upload mixed — tag in params, FormData in body',
signatureParams: [
{ name: 'tag', type: 'string' },
{ name: 'input', type: 'FormData' },
{ name: 'config', type: 'Record<string, any>' },
],
bodyOverrides: {
method: 'POST',
url: "'/api/test/upload-with-query'",
params: ['tag'],
body: 'input',
},
shouldContain: ['tag: string', 'input: FormData', 'params: { tag }', 'body: input'],
},
])('upload action signature collapses to FormData ($name)', ({ signatureParams, bodyOverrides, shouldContain }) => {
const ts = require('typescript');
const ctx = buildContext({
responseType: 'string',
responseTypeWithNamespace: 'string',
...bodyOverrides,
} as Partial<MockBody>);
ctx.methods[0].signature.parameters = signatureParams as any;
const output = render(ctx);
for (const fragment of shouldContain) {
expect(output).toContain(fragment);
}
expect(output).not.toContain('JSON.stringify');
const abpStub = `
declare module '@abp/ng.core' {
export namespace Rest {
export interface Config {
apiName?: string;
observe?: any;
skipHandleError?: boolean;
responseType?: string;
[key: string]: any;
}
export type Observe = any;
}
export class RestService {
request<TBody, TResponse>(req: any, config?: any): import('rxjs').Observable<TResponse>;
}
}
`;
const angularCoreStub = `
declare module '@angular/core' {
export function Injectable(opts?: any): ClassDecorator;
export function inject<T>(token: { new (...args: any[]): T }): T;
export function inject<T>(token: any): T;
}
`;
const rxjsStub = `
declare module 'rxjs' {
export class Observable<T> { subscribe(...args: any[]): unknown; }
}
`;
const domStub = `
declare class Blob { constructor(parts?: any[], options?: any); }
declare class FormData {
constructor();
append(name: string, value: string | Blob, fileName?: string): void;
get(name: string): any;
}
`;
const ambient = abpStub + angularCoreStub + rxjsStub + domStub;
const sources: Record<string, string> = {
'/proxy/sample.service.ts': output,
'/proxy/ambient.d.ts': ambient,
};
const compilerOptions: any = {
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.ES2020,
moduleResolution: ts.ModuleResolutionKind.NodeJs,
experimentalDecorators: true,
emitDecoratorMetadata: true,
strict: true,
noEmit: true,
skipLibCheck: true,
};
const baseHost = ts.createCompilerHost(compilerOptions, true);
const host: any = {
...baseHost,
getSourceFile: (fileName: string, languageVersion: any, onError: any) =>
sources[fileName]
? ts.createSourceFile(fileName, sources[fileName], languageVersion, true)
: baseHost.getSourceFile(fileName, languageVersion, onError),
fileExists: (fileName: string) =>
sources[fileName] != null || baseHost.fileExists(fileName),
readFile: (fileName: string) =>
sources[fileName] ?? baseHost.readFile(fileName),
};
const program = ts.createProgram(Object.keys(sources), compilerOptions, host);
const errors = ts
.getPreEmitDiagnostics(program)
.filter((d: any) => d.category === ts.DiagnosticCategory.Error && d.code !== 6053);
if (errors.length) {
const messages = errors
.map((d: any) => {
const where = d.file
? (() => {
const p = d.file.getLineAndCharacterOfPosition(d.start ?? 0);
const lineText = d.file.text.split('\n')[p.line];
return `${d.file.fileName}:${p.line + 1}:${p.character + 1}\n>>> ${lineText}\n>>> ${' '.repeat(p.character)}^`;
})()
: '(no file)';
return `[${where}] TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, '\n')}`;
})
.join('\n---\n');
throw new Error(`Upload action proxy did not compile:\n${output}\n=== diagnostics ===\n${messages}`);
}
expect(errors).toHaveLength(0);
});
test('rendered upload service forwards FormData to restService.request at runtime', () => {
const ts = require('typescript');
const ctx = buildContext({
method: 'POST',
url: "'/api/upload-runtime'",
responseType: 'string',
responseTypeWithNamespace: 'string',
body: 'input',
});
ctx.methods[0].signature.parameters = [
{ name: 'input', type: 'FormData' },
{ name: 'config', type: 'Record<string, any>' },
] as any;
const output = render(ctx);
const stripped = output
.replace(/^import .*?;\s*$/gm, '')
.replace(/@Injectable\(\{[\s\S]*?\}\)\s*\n/g, '')
.replace(/private restService = inject\(RestService\);/, 'restService;')
.replace(/this\.restService\.request<[^>]+,\s*[^>]+>/g, 'this.restService.request');
const transpiled = ts.transpileModule(stripped, {
compilerOptions: {
target: ts.ScriptTarget.ES2020,
module: ts.ModuleKind.CommonJS,
experimentalDecorators: true,
},
}).outputText;
const restMockCalls: Array<{ body: any; method: string; url: string; headers?: any }> = [];
const restMock = {
request: (req: any /* , _config: any */) => {
restMockCalls.push(req);
return { subscribe: () => undefined };
},
};
const vm = require('vm');
const sandbox: Record<string, any> = { exports: {} };
vm.createContext(sandbox);
vm.runInContext(transpiled + '\nexports.SampleService = SampleService;', sandbox);
const ServiceCls = sandbox.exports.SampleService;
const instance = new ServiceCls();
instance.restService = restMock;
const GlobalFormData = (globalThis as any).FormData;
const formData = typeof GlobalFormData === 'function'
? new GlobalFormData()
: { __isFormData: true, append: () => undefined };
instance.getSampleAsync(formData, { apiName: 'Default' });
expect(restMockCalls).toHaveLength(1);
expect(restMockCalls[0].body).toBe(formData);
expect(restMockCalls[0].method).toBe('POST');
});
});

144
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<T>, IEnumerable<T>, ICollection<T>, Collection<T>, IList<T>
// This matches any generic type from System.Collections.Generic that implements IEnumerable<T>
// 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])) {

Loading…
Cancel
Save