diff --git a/src/Http/Http.Abstractions/src/Metadata/IFromFormMetadata.cs b/src/Http/Http.Abstractions/src/Metadata/IFromFormMetadata.cs
new file mode 100644
index 000000000000..96b872eb3f13
--- /dev/null
+++ b/src/Http/Http.Abstractions/src/Metadata/IFromFormMetadata.cs
@@ -0,0 +1,15 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Http.Metadata;
+
+///
+/// Interface marking attributes that specify a parameter should be bound using a form field from the request body.
+///
+public interface IFromFormMetadata
+{
+ ///
+ /// The form field name.
+ ///
+ string? Name { get; }
+}
diff --git a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
index 244ddbf827dc..f5c14748ac9e 100644
--- a/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
+++ b/src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt
@@ -1,3 +1,5 @@
#nullable enable
*REMOVED*abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string!
+Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata
+Microsoft.AspNetCore.Http.Metadata.IFromFormMetadata.Name.get -> string?
abstract Microsoft.AspNetCore.Http.HttpResponse.ContentType.get -> string?
diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs
index 5864b8bd27dd..0035808cbb3b 100644
--- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs
+++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs
@@ -14,6 +14,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Internal;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Primitives;
namespace Microsoft.AspNetCore.Http;
@@ -59,6 +60,8 @@ public static partial class RequestDelegateFactory
private static readonly MemberExpression RouteValuesExpr = Expression.Property(HttpRequestExpr, typeof(HttpRequest).GetProperty(nameof(HttpRequest.RouteValues))!);
private static readonly MemberExpression QueryExpr = Expression.Property(HttpRequestExpr, typeof(HttpRequest).GetProperty(nameof(HttpRequest.Query))!);
private static readonly MemberExpression HeadersExpr = Expression.Property(HttpRequestExpr, typeof(HttpRequest).GetProperty(nameof(HttpRequest.Headers))!);
+ private static readonly MemberExpression FormExpr = Expression.Property(HttpRequestExpr, typeof(HttpRequest).GetProperty(nameof(HttpRequest.Form))!);
+ private static readonly MemberExpression FormFilesExpr = Expression.Property(FormExpr, typeof(IFormCollection).GetProperty(nameof(IFormCollection.Files))!);
private static readonly MemberExpression StatusCodeExpr = Expression.Property(HttpResponseExpr, typeof(HttpResponse).GetProperty(nameof(HttpResponse.StatusCode))!);
private static readonly MemberExpression CompletedTaskExpr = Expression.Property(null, (PropertyInfo)GetMemberInfo>(() => Task.CompletedTask));
@@ -66,6 +69,7 @@ public static partial class RequestDelegateFactory
private static readonly BinaryExpression TempSourceStringNotNullExpr = Expression.NotEqual(TempSourceStringExpr, Expression.Constant(null));
private static readonly BinaryExpression TempSourceStringNullExpr = Expression.Equal(TempSourceStringExpr, Expression.Constant(null));
private static readonly string[] DefaultAcceptsContentType = new[] { "application/json" };
+ private static readonly string[] FormFileContentType = new[] { "multipart/form-data" };
///
/// Creates a implementation for .
@@ -195,6 +199,12 @@ private static Expression[] CreateArguments(ParameterInfo[]? parameters, Factory
var errorMessage = BuildErrorMessageForInferredBodyParameter(factoryContext);
throw new InvalidOperationException(errorMessage);
}
+ if (factoryContext.JsonRequestBodyParameter is not null &&
+ factoryContext.FirstFormRequestBodyParameter is not null)
+ {
+ var errorMessage = BuildErrorMessageForFormAndJsonBodyParameters(factoryContext);
+ throw new InvalidOperationException(errorMessage);
+ }
if (factoryContext.HasMultipleBodyParameters)
{
var errorMessage = BuildErrorMessageForMultipleBodyParameters(factoryContext);
@@ -239,6 +249,26 @@ private static Expression CreateArgument(ParameterInfo parameter, FactoryContext
factoryContext.TrackedParameters.Add(parameter.Name, RequestDelegateFactoryConstants.BodyAttribute);
return BindParameterFromBody(parameter, bodyAttribute.AllowEmpty, factoryContext);
}
+ else if (parameterCustomAttributes.OfType().FirstOrDefault() is { } formAttribute)
+ {
+ if (parameter.ParameterType == typeof(IFormFileCollection))
+ {
+ if (!string.IsNullOrEmpty(formAttribute.Name))
+ {
+ throw new NotSupportedException(
+ $"Assigning a value to the {nameof(IFromFormMetadata)}.{nameof(IFromFormMetadata.Name)} property is not supported for parameters of type {nameof(IFormFileCollection)}.");
+ }
+
+ return BindParameterFromFormFiles(parameter, factoryContext);
+ }
+ else if (parameter.ParameterType != typeof(IFormFile))
+ {
+ throw new NotSupportedException(
+ $"{nameof(IFromFormMetadata)} is only supported for parameters of type {nameof(IFormFileCollection)} and {nameof(IFormFile)}.");
+ }
+
+ return BindParameterFromFormFile(parameter, formAttribute.Name ?? parameter.Name, factoryContext, RequestDelegateFactoryConstants.FormFileAttribute);
+ }
else if (parameter.CustomAttributes.Any(a => typeof(IFromServiceMetadata).IsAssignableFrom(a.AttributeType)))
{
factoryContext.TrackedParameters.Add(parameter.Name, RequestDelegateFactoryConstants.ServiceAttribute);
@@ -264,6 +294,14 @@ private static Expression CreateArgument(ParameterInfo parameter, FactoryContext
{
return RequestAbortedExpr;
}
+ else if (parameter.ParameterType == typeof(IFormFileCollection))
+ {
+ return BindParameterFromFormFiles(parameter, factoryContext);
+ }
+ else if (parameter.ParameterType == typeof(IFormFile))
+ {
+ return BindParameterFromFormFile(parameter, parameter.Name, factoryContext, RequestDelegateFactoryConstants.FormFileParameter);
+ }
else if (ParameterBindingMethodCache.HasBindAsyncMethod(parameter))
{
return BindParameterFromBindAsync(parameter, factoryContext);
@@ -511,7 +549,7 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall,
private static Func
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
-public class FromFormAttribute : Attribute, IBindingSourceMetadata, IModelNameProvider
+public class FromFormAttribute : Attribute, IBindingSourceMetadata, IModelNameProvider, IFromFormMetadata
{
///
public BindingSource BindingSource => BindingSource.Form;
diff --git a/src/Mvc/test/Mvc.FunctionalTests/SimpleWithWebApplicationBuilderTests.cs b/src/Mvc/test/Mvc.FunctionalTests/SimpleWithWebApplicationBuilderTests.cs
index dddaaf110dbd..367fcdf4f79c 100644
--- a/src/Mvc/test/Mvc.FunctionalTests/SimpleWithWebApplicationBuilderTests.cs
+++ b/src/Mvc/test/Mvc.FunctionalTests/SimpleWithWebApplicationBuilderTests.cs
@@ -247,4 +247,23 @@ public async Task Accepts_NonJsonMediaType()
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.Accepted);
}
+
+ [Fact]
+ public async Task FileUpload_Works()
+ {
+ // Arrange
+ var expected = "42";
+ var content = new MultipartFormDataContent();
+ content.Add(new StringContent(new string('a', 42)), "file", "file.txt");
+
+ using var client = _fixture.CreateDefaultClient();
+
+ // Act
+ var response = await client.PostAsync("/fileupload", content);
+
+ // Assert
+ await response.AssertStatusCodeAsync(HttpStatusCode.OK);
+ var actual = await response.Content.ReadAsStringAsync();
+ Assert.Equal(expected, actual);
+ }
}
diff --git a/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilder/Program.cs b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilder/Program.cs
index 71d77264a07f..768b1dc7fb97 100644
--- a/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilder/Program.cs
+++ b/src/Mvc/test/WebSites/SimpleWebSiteWithWebApplicationBuilder/Program.cs
@@ -41,6 +41,12 @@
app.MapPost("/accepts-default", (Person person) => Results.Ok(person.Name));
app.MapPost("/accepts-xml", () => Accepted()).Accepts("application/xml");
+app.MapPost("/fileupload", async (IFormFile file) =>
+{
+ await using var uploadStream = file.OpenReadStream();
+ return uploadStream.Length;
+});
+
app.Run();
record Person(string Name, int Age);