Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
89772d2
Add ContentTypes and IsRemoteStream to ReturnValueApiDescriptionModel
maliming Jun 16, 2026
6844e6d
Extract response ContentTypes from ApiDescription in MVC provider
maliming Jun 16, 2026
a8d93a2
Use ContentTypes for dataType and Accept in jQuery proxy generator
maliming Jun 16, 2026
5fb3b26
Add Accept header and JSON string unwrap to ClientProxyBase
maliming Jun 16, 2026
a765cc0
Add end-to-end ClientProxy tests for ContentTypes scenarios
maliming Jun 16, 2026
e7b37b7
Generate Angular proxies with responseType and Accept from ContentTypes
maliming Jun 16, 2026
02c6da1
Auto-set Accept header and unwrap ABP JSON error envelopes in RestSer…
maliming Jun 16, 2026
fba75de
Skip dataType override for IRemoteStreamContent in jQuery proxy gener…
maliming Jun 16, 2026
cb7e8af
Refine ClientProxyBase Accept handling and narrow remote stream cast
maliming Jun 16, 2026
f057009
Echo declared Accept media types in jQuery proxy generator
maliming Jun 16, 2026
49c4ff5
Unwrap ABP error envelope from Blob body and validationErrors-only pa…
maliming Jun 16, 2026
b0e4a94
Echo declared Accept media types and degrade IRemoteStreamContent col…
maliming Jun 16, 2026
025e495
Upgrade proxy service template test to ts.createProgram for real sema…
maliming Jun 16, 2026
b39aa93
Forward IRemoteStreamContent DTO uploads as multipart in jQuery and A…
maliming Jun 17, 2026
1a65d79
Cover Angular signature/method mappers and pin jQuery multi-FormFile …
maliming Jun 17, 2026
68b6b0a
Pin Angular body mapper limit for multiple direct FormFile method args
maliming Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,8 @@ private async Task AddApiDescriptionToModelAsync(
GetSupportedVersions(controllerType, method, setting),
allowAnonymous,
authorizeModels,
implementFrom
implementFrom,
GetReturnValueContentTypes(apiDescription)
)
);

Expand All @@ -199,6 +200,27 @@ private async Task AddApiDescriptionToModelAsync(
}
}

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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,7 @@ protected virtual async Task<T> RequestAsync<T>(ClientProxyRequestContext reques
{
var responseContent = await RequestAsync(requestContext);

if (typeof(T) == typeof(IRemoteStreamContent) ||
typeof(T) == typeof(RemoteStreamContent))
if (typeof(T) == typeof(IRemoteStreamContent) || typeof(T) == typeof(RemoteStreamContent))
{
/* returning a class that holds a reference to response
* content just to be sure that GC does not dispose of
Expand All @@ -127,7 +126,8 @@ await responseContent.ReadAsStreamAsync(),
var stringContent = await responseContent.ReadAsStringAsync();
if (typeof(T) == typeof(string))
{
return (T)(object)stringContent;
var unwrapped = UnwrapStringResponse(stringContent, responseContent.Headers?.ContentType?.MediaType);
return (T)(object)unwrapped!;
}

if (stringContent.IsNullOrWhiteSpace())
Expand All @@ -139,6 +139,39 @@ await responseContent.ReadAsStreamAsync(),
}
}

protected virtual string? UnwrapStringResponse(string body, string? contentType)
{
if (body.IsNullOrEmpty() || contentType.IsNullOrWhiteSpace())
{
return body;
}

if (!IsJsonMediaType(NormalizeMediaType(contentType!)))
{
return body;
}

try
{
var parsed = JsonSerializer.Deserialize<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}.");
Expand Down Expand Up @@ -326,14 +359,7 @@ protected virtual void AddHeaders(
HttpRequestMessage requestMessage,
ApiVersionInfo apiVersion)
{
//API Version
if (!apiVersion.Version.IsNullOrEmpty())
{
//TODO: What about other media types?
requestMessage.Headers.Add("accept", $"{MimeTypes.Text.Plain}; v={apiVersion.Version}");
requestMessage.Headers.Add("accept", $"{MimeTypes.Application.Json}; v={apiVersion.Version}");
requestMessage.Headers.Add("api-version", apiVersion.Version);
}
AddAcceptHeaders(action, requestMessage, apiVersion);

//Header parameters
var headers = action.Parameters.Where(p => p.BindingSourceId == ParameterBindingSources.Header).ToArray();
Expand Down Expand Up @@ -378,6 +404,57 @@ protected virtual void AddHeaders(
}
}

protected virtual void AddAcceptHeaders(
ActionApiDescriptionModel action,
HttpRequestMessage requestMessage,
ApiVersionInfo apiVersion)
{
var acceptForReturn = GetAcceptForActionReturn(action);
var versionSuffix = apiVersion.Version.IsNullOrEmpty() ? string.Empty : $"; v={apiVersion.Version}";

if (!acceptForReturn.IsNullOrEmpty())
{
requestMessage.Headers.Add("accept", acceptForReturn + versionSuffix);
}
else
{
requestMessage.Headers.Add("accept", MimeTypes.Text.Plain + versionSuffix);
requestMessage.Headers.Add("accept", MimeTypes.Application.Json + versionSuffix);
}

if (!apiVersion.Version.IsNullOrEmpty())
{
requestMessage.Headers.Add("api-version", apiVersion.Version);
}
}

protected virtual string? GetAcceptForActionReturn(ActionApiDescriptionModel action)
{
if (action.ReturnValue.IsRemoteStream ||
action.ReturnValue.Type == typeof(IRemoteStreamContent).FullName ||
action.ReturnValue.Type == typeof(RemoteStreamContent).FullName)
{
return MimeTypes.Application.OctetStream;
}

var contentTypes = action.ReturnValue.ContentTypes;
if (contentTypes == null || contentTypes.Count == 0)
{
return null;
}

var normalized = contentTypes.Select(NormalizeMediaType).ToList();

return normalized.FirstOrDefault(IsJsonMediaType) ?? normalized[0];
}

private static bool IsJsonMediaType(string normalizedMediaType)
{
return normalizedMediaType.Equals(MimeTypes.Application.Json, StringComparison.OrdinalIgnoreCase) ||
normalizedMediaType.Equals("text/json", StringComparison.OrdinalIgnoreCase) ||
normalizedMediaType.EndsWith("+json", StringComparison.OrdinalIgnoreCase);
}

protected virtual StringSegment RemoveQuotes(StringSegment input)
{
if (!StringSegment.IsNullOrEmpty(input) && input.Length >= 2 && input[0] == '"' && input[input.Length - 1] == '"')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public 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));
Expand All @@ -58,7 +58,7 @@ public static ActionApiDescriptionModel Create([NotNull] string uniqueName, [Not
Name = method.Name,
Url = url,
HttpMethod = httpMethod,
ReturnValue = ReturnValueApiDescriptionModel.Create(method.ReturnType),
ReturnValue = ReturnValueApiDescriptionModel.Create(method.ReturnType, returnValueContentTypes),
Parameters = new List<ParameterApiDescriptionModel>(),
ParametersOnMethod = method
.GetParameters()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using Volo.Abp.Content;
using Volo.Abp.Reflection;
using Volo.Abp.Threading;

Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -135,17 +135,64 @@ private static void AddActionScript(StringBuilder script, string controllerName,

AddAjaxCallParameters(script, action);

var ajaxParamsIsFromForm = action.Parameters.Any(x => x.BindingSourceId == ParameterBindingSources.Form);
var dataType = action.ReturnValue.Type == ReturnValueApiDescriptionModel.Create(typeof(string)).Type
? "{ dataType: 'text' }, "
: string.Empty;
var hasFormFile = action.Parameters.Any(x => x.BindingSourceId == ParameterBindingSources.FormFile);
var ajaxParamsIsFromForm = !hasFormFile && action.Parameters.Any(x => x.BindingSourceId == ParameterBindingSources.Form);
var dataType = GetJQueryDataTypeAndAcceptOverride(action);
script.AppendLine(ajaxParamsIsFromForm
? " }, $.extend(true, {}, " + dataType + "{ contentType: 'application/x-www-form-urlencoded; charset=UTF-8' }, ajaxParams)));"
: " }, " + dataType + "ajaxParams));");

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
Expand Down Expand Up @@ -184,6 +231,20 @@ private static void AddAjaxCallParameters(StringBuilder script, ActionApiDescrip
script.Append(" headers: " + headers);
}

var firstFileParam = action.Parameters.FirstOrDefault(p => p.BindingSourceId == ParameterBindingSources.FormFile);
if (firstFileParam != null)
{
var fileVar = ProxyScriptingJsFuncHelper.NormalizeJsVariableName(firstFileParam.NameOnMethod.ToCamelCase());
script.AppendLine(",");
script.Append(" data: " + fileVar + ",");
script.AppendLine();
script.Append(" processData: false,");
script.AppendLine();
script.Append(" contentType: false");
script.AppendLine();
return;
}

var body = ProxyScriptingHelper.GenerateBody(action);
if (!body.IsNullOrEmpty())
{
Expand Down
Loading
Loading