Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
90 changes: 77 additions & 13 deletions src/Http/Http.Extensions/src/RequestDelegateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,30 @@ public static partial class RequestDelegateFactory
private static readonly string[] PlaintextContentType = new[] { "text/plain" };
private static readonly Type[] StringTypes = new[] {typeof(string), typeof(StringValues), typeof(StringValues?) };

// Helper method to check if a type is a collection interface or List<T> that can be treated as an array
private static bool IsBindableCollectionInterface(Type type, out Type? elementType)
{
elementType = null;

// Check for IEnumerable<T>, IList<T>, ICollection<T>, List<T>
if (type.IsGenericType)
{
var genericTypeDefinition = type.GetGenericTypeDefinition();
if (genericTypeDefinition == typeof(IEnumerable<>) ||
genericTypeDefinition == typeof(IList<>) ||
genericTypeDefinition == typeof(ICollection<>) ||
genericTypeDefinition == typeof(List<>))
{
elementType = type.GetGenericArguments()[0];
// Only use simple binding for collection types when element type is string or has TryParse
// This ensures we don't try to use TryParse on string itself
return true;
}
}

return false;
}

/// <summary>
/// Returns metadata inferred automatically for the <see cref="RequestDelegate"/> created by <see cref="Create(Delegate, RequestDelegateFactoryOptions?, RequestDelegateMetadataResult?)"/>.
/// This includes metadata inferred by <see cref="IEndpointMetadataProvider"/> and <see cref="IEndpointParameterMetadataProvider"/> implemented by parameter and return types to the <paramref name="methodInfo"/>.
Expand Down Expand Up @@ -796,7 +820,13 @@ private static Expression CreateArgument(ParameterInfo parameter, RequestDelegat
ParameterBindingMethodCache.Instance.HasTryParseMethod(parameter.ParameterType) ||
(parameter.ParameterType.IsArray &&
(StringTypes.Contains(parameter.ParameterType.GetElementType()) ||
ParameterBindingMethodCache.Instance.HasTryParseMethod(parameter.ParameterType.GetElementType()!)));
ParameterBindingMethodCache.Instance.HasTryParseMethod(parameter.ParameterType.GetElementType()!))) ||
// Only use simple binding for collection interfaces when used as properties in AsParameters
// Direct [FromForm] parameters with collection types should use complex binding (FormDataMapper)
(parameter is PropertyAsParameterInfo &&
IsBindableCollectionInterface(parameter.ParameterType, out var collectionElementType) &&
(StringTypes.Contains(collectionElementType) ||
ParameterBindingMethodCache.Instance.HasTryParseMethod(collectionElementType!)));
hasTryParse = useSimpleBinding;
return useSimpleBinding
? BindParameterFromFormItem(parameter, formAttribute.Name ?? parameter.Name, factoryContext)
Expand Down Expand Up @@ -1677,15 +1707,29 @@ private static Expression BindParameterFromValue(ParameterInfo parameter, Expres
}

var isOptional = IsOptionalParameter(parameter, factoryContext);
var argument = Expression.Variable(parameter.ParameterType, $"{parameter.Name}_local");

// Check if this is a collection interface that we'll bind as an array
var isCollectionInterface = IsBindableCollectionInterface(parameter.ParameterType, out var collectionElementType);

// For collection interfaces, we'll create an array internally and cast to the interface
var actualParameterType = isCollectionInterface ? collectionElementType!.MakeArrayType() : parameter.ParameterType;
var argument = Expression.Variable(actualParameterType, $"{parameter.Name}_local");

var parameterTypeNameConstant = Expression.Constant(TypeNameHelper.GetTypeDisplayName(parameter.ParameterType, fullName: false));
var parameterNameConstant = Expression.Constant(parameter.Name);
var sourceConstant = Expression.Constant(source);

factoryContext.UsingTempSourceString = true;

var targetParseType = parameter.ParameterType.IsArray ? parameter.ParameterType.GetElementType()! : parameter.ParameterType;
var targetParseType = (parameter.ParameterType.IsArray || isCollectionInterface)
? (isCollectionInterface ? collectionElementType! : parameter.ParameterType.GetElementType()!)
: parameter.ParameterType;

// If the target type is a string type (for arrays/collections of strings), use the expression binding
if (StringTypes.Contains(targetParseType))
{
return BindParameterFromExpression(parameter, valueExpression, factoryContext, source);
}

var underlyingNullableType = Nullable.GetUnderlyingType(targetParseType);
var isNotNullable = underlyingNullableType is null;
Expand Down Expand Up @@ -1793,30 +1837,32 @@ private static Expression BindParameterFromValue(ParameterInfo parameter, Expres
var index = Expression.Variable(typeof(int), "index");

// If the parameter is nullable, we need to assign the "parsedValue" local to the nullable parameter on success.
var isArrayOrCollection = parameter.ParameterType.IsArray || isCollectionInterface;
var tryParseExpression = Expression.Block(new[] { parsedValue },
Expression.IfThenElse(tryParseCall,
Expression.Assign(parameter.ParameterType.IsArray ? Expression.ArrayAccess(argument, index) : argument, Expression.Convert(parsedValue, targetParseType)),
Expression.Assign(isArrayOrCollection ? Expression.ArrayAccess(argument, index) : argument, Expression.Convert(parsedValue, targetParseType)),
failBlock));

var ifNotNullTryParse = !parameter.HasDefaultValue
? Expression.IfThen(TempSourceStringNotNullExpr, tryParseExpression)
: Expression.IfThenElse(TempSourceStringNotNullExpr, tryParseExpression,
Expression.Assign(argument,
CreateDefaultValueExpression(parameter.DefaultValue, parameter.ParameterType)));
CreateDefaultValueExpression(parameter.DefaultValue, isCollectionInterface ? actualParameterType : parameter.ParameterType)));

var loopExit = Expression.Label();

// REVIEW: We can reuse this like we reuse temp source string
var stringArrayExpr = parameter.ParameterType.IsArray ? Expression.Variable(typeof(string[]), "tempStringArray") : null;
var elementTypeNullabilityInfo = parameter.ParameterType.IsArray ? factoryContext.NullabilityContext.Create(parameter)?.ElementType : null;
var stringArrayExpr = isArrayOrCollection ? Expression.Variable(typeof(string[]), "tempStringArray") : null;
var elementTypeNullabilityInfo = isArrayOrCollection ? factoryContext.NullabilityContext.Create(parameter)?.ElementType : null;

// Determine optionality of the element type of the array
var elementTypeOptional = !isNotNullable || (elementTypeNullabilityInfo?.ReadState != NullabilityState.NotNull);

// The loop that populates the resulting array values
var arrayLoop = parameter.ParameterType.IsArray ? Expression.Block(
var arrayElementType = isCollectionInterface ? collectionElementType! : parameter.ParameterType.GetElementType()!;
var arrayLoop = isArrayOrCollection ? Expression.Block(
// param_local = new int[values.Length];
Expression.Assign(argument, Expression.NewArrayBounds(parameter.ParameterType.GetElementType()!, Expression.ArrayLength(stringArrayExpr!))),
Expression.Assign(argument, Expression.NewArrayBounds(arrayElementType, Expression.ArrayLength(stringArrayExpr!))),
// index = 0
Expression.Assign(index, Expression.Constant(0)),
// while (index < values.Length)
Expand All @@ -1839,7 +1885,7 @@ private static Expression BindParameterFromValue(ParameterInfo parameter, Expres
, loopExit)
) : null;

var fullParamCheckBlock = (parameter.ParameterType.IsArray, isOptional) switch
var fullParamCheckBlock = (isArrayOrCollection, isOptional) switch
{
// (isArray: true, optional: true)
(true, true) =>
Expand Down Expand Up @@ -1892,6 +1938,22 @@ private static Expression BindParameterFromValue(ParameterInfo parameter, Expres
factoryContext.ExtraLocals.Add(argument);
factoryContext.ParamCheckExpressions.Add(fullParamCheckBlock);

// For collection interfaces, we created an array internally
if (isCollectionInterface)
{
// For List<T>, use the constructor: new List<T>(array)
if (parameter.ParameterType.IsGenericType && parameter.ParameterType.GetGenericTypeDefinition() == typeof(List<>))
{
var listConstructor = parameter.ParameterType.GetConstructor(new[] { actualParameterType });
return Expression.New(listConstructor!, argument);
}
// For interfaces (IEnumerable<T>, IList<T>, ICollection<T>), just cast the array
else
{
return Expression.Convert(argument, parameter.ParameterType);
}
}

return argument;
}

Expand Down Expand Up @@ -1971,13 +2033,15 @@ private static Expression BindParameterFromProperty(ParameterInfo parameter, Mem
{
var valueExpression = (source == "header" && parameter.ParameterType.IsArray)
? Expression.Call(GetHeaderSplitMethod, property, Expression.Constant(key))
: GetValueFromProperty(property, itemProperty, key, GetExpressionType(parameter.ParameterType));
: GetValueFromProperty(property, itemProperty, key, GetExpressionType(parameter.ParameterType, parameter));

return BindParameterFromValue(parameter, valueExpression, factoryContext, source);
}

private static Type? GetExpressionType(Type type) =>
private static Type? GetExpressionType(Type type, ParameterInfo? parameter = null) =>
type.IsArray ? typeof(string[]) :
// Only treat collection interfaces as string[] when used as AsParameters properties
(parameter is PropertyAsParameterInfo && IsBindableCollectionInterface(type, out _)) ? typeof(string[]) :
type == typeof(StringValues) ? typeof(StringValues) :
type == typeof(StringValues?) ? typeof(StringValues?) :
null;
Expand Down Expand Up @@ -2112,7 +2176,7 @@ private static Expression BindParameterFromFormItem(
string key,
RequestDelegateFactoryContext factoryContext)
{
var valueExpression = GetValueFromProperty(FormExpr, FormIndexerProperty, key, GetExpressionType(parameter.ParameterType));
var valueExpression = GetValueFromProperty(FormExpr, FormIndexerProperty, key, GetExpressionType(parameter.ParameterType, parameter));

factoryContext.FirstFormRequestBodyParameter ??= parameter;
factoryContext.TrackedParameters.Add(key, RequestDelegateFactoryConstants.FormAttribute);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,4 +322,154 @@ private class FormFileDto
public IReadOnlyList<IFormFile>? FormFiles { get; set; }
public IFormFileCollection? FormFileCollection { get; set; }
}

// Test for issue #62329 - AsParameters with FromForm and IEnumerable properties
[Fact]
public async Task RequestDelegateHandlesAsParametersWithFromFormIEnumerable()
{
var httpContext = CreateHttpContext();
var formFiles = new FormFileCollection
{
new FormFile(Stream.Null, 0, 10, "File", "test.txt")
};
httpContext.Request.Form = new FormCollection(
new Dictionary<string, StringValues>
{
["Values"] = new(new[] { "1", "2", "3" })
},
formFiles);

var factoryResult = RequestDelegateFactory.Create(
([AsParameters] DocumentUploadRequest request) =>
{
return TypedResults.Ok();
});

var requestDelegate = factoryResult.RequestDelegate;

await requestDelegate(httpContext);

Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
}

public class DocumentUploadRequest
{
public IFormFile? File { get; set; }

[FromForm]
public IEnumerable<int>? Values { get; set; }
}

// Additional tests for other collection interface types
[Fact]
public async Task RequestDelegateHandlesAsParametersWithFromFormIList()
{
var httpContext = CreateHttpContext();
httpContext.Request.Form = new FormCollection(
new Dictionary<string, StringValues>
{
["Values"] = new(new[] { "10", "20", "30" })
});

var factoryResult = RequestDelegateFactory.Create(
([AsParameters] DocumentUploadRequestIList request) =>
{
return TypedResults.Ok(request.Values);
});

var requestDelegate = factoryResult.RequestDelegate;
await requestDelegate(httpContext);

Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
}

[Fact]
public async Task RequestDelegateHandlesAsParametersWithFromFormList()
{
var httpContext = CreateHttpContext();
httpContext.Request.Form = new FormCollection(
new Dictionary<string, StringValues>
{
["Items"] = new(new[] { "100", "200" })
});

var factoryResult = RequestDelegateFactory.Create(
([AsParameters] DocumentUploadRequestList request) =>
{
return TypedResults.Ok(request.Items);
});

var requestDelegate = factoryResult.RequestDelegate;
await requestDelegate(httpContext);

Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
}

[Fact]
public async Task RequestDelegateHandlesAsParametersWithFromFormIEnumerableString()
{
var httpContext = CreateHttpContext();
httpContext.Request.Form = new FormCollection(
new Dictionary<string, StringValues>
{
["Tags"] = new(new[] { "tag1", "tag2", "tag3" })
});

var factoryResult = RequestDelegateFactory.Create(
([AsParameters] DocumentUploadRequestStringEnum request) =>
{
return TypedResults.Ok(request.Tags);
});

var requestDelegate = factoryResult.RequestDelegate;
await requestDelegate(httpContext);

Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
}

public class DocumentUploadRequestIList
{
[FromForm]
public IList<int>? Values { get; set; }
}

public class DocumentUploadRequestList
{
[FromForm]
public List<int>? Items { get; set; }
}

public class DocumentUploadRequestStringEnum
{
[FromForm]
public IEnumerable<string>? Tags { get; set; }
}

[Fact]
public async Task RequestDelegateHandlesAsParametersWithFromFormIntArray()
{
var httpContext = CreateHttpContext();
httpContext.Request.Form = new FormCollection(
new Dictionary<string, StringValues>
{
["Numbers"] = new(new[] { "42", "84", "126" })
});

var factoryResult = RequestDelegateFactory.Create(
([AsParameters] DocumentUploadRequestIntArray request) =>
{
return TypedResults.Ok(request.Numbers);
});

var requestDelegate = factoryResult.RequestDelegate;
await requestDelegate(httpContext);

Assert.Equal(StatusCodes.Status200OK, httpContext.Response.StatusCode);
}

public class DocumentUploadRequestIntArray
{
[FromForm]
public int[]? Numbers { get; set; }
}
}
Loading