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 HandleRequestBodyAndCompileRequestDelegate(Expression responseWritingMethodCall, FactoryContext factoryContext) { - if (factoryContext.JsonRequestBodyParameter is null) + if (factoryContext.JsonRequestBodyParameter is null && !factoryContext.ReadForm) { if (factoryContext.ParameterBinders.Count > 0) { @@ -540,19 +578,26 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, responseWritingMethodCall, TargetExpr, HttpContextExpr).Compile(); } + if (factoryContext.ReadForm) + { + return HandleRequestBodyAndCompileRequestDelegateForForm(responseWritingMethodCall, factoryContext); + } + else + { + return HandleRequestBodyAndCompileRequestDelegateForJson(responseWritingMethodCall, factoryContext); + } + } + + private static Func HandleRequestBodyAndCompileRequestDelegateForJson(Expression responseWritingMethodCall, FactoryContext factoryContext) + { + Debug.Assert(factoryContext.JsonRequestBodyParameter is not null, "factoryContext.JsonRequestBodyParameter is null for a JSON body."); + var bodyType = factoryContext.JsonRequestBodyParameter.ParameterType; var parameterTypeName = TypeNameHelper.GetTypeDisplayName(factoryContext.JsonRequestBodyParameter.ParameterType, fullName: false); var parameterName = factoryContext.JsonRequestBodyParameter.Name; Debug.Assert(parameterName is not null, "CreateArgument() should throw if parameter.Name is null."); - object? defaultBodyValue = null; - - if (factoryContext.AllowEmptyRequestBody && bodyType.IsValueType) - { - defaultBodyValue = Activator.CreateInstance(bodyType); - } - if (factoryContext.ParameterBinders.Count > 0) { // We need to generate the code for reading from the body before calling into the delegate @@ -565,39 +610,25 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, return async (target, httpContext) => { - // Run these first so that they can potentially read and rewind the body - var boundValues = new object?[count]; + // Run these first so that they can potentially read and rewind the body + var boundValues = new object?[count]; for (var i = 0; i < count; i++) { boundValues[i] = await binders[i](httpContext); } - var bodyValue = defaultBodyValue; - var feature = httpContext.Features.Get(); - if (feature?.CanHaveBody == true) + var (bodyValue, successful) = await TryReadBodyAsync( + httpContext, + bodyType, + parameterTypeName, + parameterName, + factoryContext.AllowEmptyRequestBody, + factoryContext.ThrowOnBadRequest); + + if (!successful) { - if (!httpContext.Request.HasJsonContentType()) - { - Log.UnexpectedContentType(httpContext, httpContext.Request.ContentType, factoryContext.ThrowOnBadRequest); - httpContext.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType; - return; - } - try - { - bodyValue = await httpContext.Request.ReadFromJsonAsync(bodyType); - } - catch (IOException ex) - { - Log.RequestBodyIOException(httpContext, ex); - return; - } - catch (JsonException ex) - { - Log.InvalidJsonRequestBody(httpContext, parameterTypeName, parameterName, ex, factoryContext.ThrowOnBadRequest); - httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; - return; - } + return; } await continuation(target, httpContext, bodyValue, boundValues); @@ -611,44 +642,214 @@ private static Expression AddResponseWritingToMethodCall(Expression methodCall, return async (target, httpContext) => { - var bodyValue = defaultBodyValue; - var feature = httpContext.Features.Get(); - if (feature?.CanHaveBody == true) + var (bodyValue, successful) = await TryReadBodyAsync( + httpContext, + bodyType, + parameterTypeName, + parameterName, + factoryContext.AllowEmptyRequestBody, + factoryContext.ThrowOnBadRequest); + + if (!successful) { - if (!httpContext.Request.HasJsonContentType()) - { - Log.UnexpectedContentType(httpContext, httpContext.Request.ContentType, factoryContext.ThrowOnBadRequest); - httpContext.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType; - return; - } - try - { - bodyValue = await httpContext.Request.ReadFromJsonAsync(bodyType); - } - catch (IOException ex) - { - Log.RequestBodyIOException(httpContext, ex); - return; - } - catch (JsonException ex) - { - - Log.InvalidJsonRequestBody(httpContext, parameterTypeName, parameterName, ex, factoryContext.ThrowOnBadRequest); - httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; - return; - } + return; } + await continuation(target, httpContext, bodyValue); }; } + + static async Task<(object? FormValue, bool Successful)> TryReadBodyAsync( + HttpContext httpContext, + Type bodyType, + string parameterTypeName, + string parameterName, + bool allowEmptyRequestBody, + bool throwOnBadRequest) + { + object? defaultBodyValue = null; + + if (allowEmptyRequestBody && bodyType.IsValueType) + { + defaultBodyValue = Activator.CreateInstance(bodyType); + } + + var bodyValue = defaultBodyValue; + var feature = httpContext.Features.Get(); + + if (feature?.CanHaveBody == true) + { + if (!httpContext.Request.HasJsonContentType()) + { + Log.UnexpectedJsonContentType(httpContext, httpContext.Request.ContentType, throwOnBadRequest); + httpContext.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType; + return (null, false); + } + try + { + bodyValue = await httpContext.Request.ReadFromJsonAsync(bodyType); + } + catch (IOException ex) + { + Log.RequestBodyIOException(httpContext, ex); + return (null, false); + } + catch (JsonException ex) + { + Log.InvalidJsonRequestBody(httpContext, parameterTypeName, parameterName, ex, throwOnBadRequest); + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + return (null, false); + } + } + + return (bodyValue, true); + } } - private static Expression GetValueFromProperty(Expression sourceExpression, string key) + private static Func HandleRequestBodyAndCompileRequestDelegateForForm( + Expression responseWritingMethodCall, + FactoryContext factoryContext) + { + Debug.Assert(factoryContext.FirstFormRequestBodyParameter is not null, "factoryContext.FirstFormRequestBodyParameter is null for a form body."); + + // If there are multiple parameters associated with the form, just use the name of + // the first one to report the failure to bind the parameter if reading the form fails. + var parameterTypeName = TypeNameHelper.GetTypeDisplayName(factoryContext.FirstFormRequestBodyParameter.ParameterType, fullName: false); + var parameterName = factoryContext.FirstFormRequestBodyParameter.Name; + + Debug.Assert(parameterName is not null, "CreateArgument() should throw if parameter.Name is null."); + + if (factoryContext.ParameterBinders.Count > 0) + { + // We need to generate the code for reading from the body or form before calling into the delegate + var continuation = Expression.Lambda>( + responseWritingMethodCall, TargetExpr, HttpContextExpr, BodyValueExpr, BoundValuesArrayExpr).Compile(); + + // Looping over arrays is faster + var binders = factoryContext.ParameterBinders.ToArray(); + var count = binders.Length; + + return async (target, httpContext) => + { + // Run these first so that they can potentially read and rewind the body + var boundValues = new object?[count]; + + for (var i = 0; i < count; i++) + { + boundValues[i] = await binders[i](httpContext); + } + + var (formValue, successful) = await TryReadFormAsync( + httpContext, + parameterTypeName, + parameterName, + factoryContext.ThrowOnBadRequest); + + if (!successful) + { + return; + } + + await continuation(target, httpContext, formValue, boundValues); + }; + } + else + { + // We need to generate the code for reading from the form before calling into the delegate + var continuation = Expression.Lambda>( + responseWritingMethodCall, TargetExpr, HttpContextExpr, BodyValueExpr).Compile(); + + return async (target, httpContext) => + { + var (formValue, successful) = await TryReadFormAsync( + httpContext, + parameterTypeName, + parameterName, + factoryContext.ThrowOnBadRequest); + + if (!successful) + { + return; + } + + await continuation(target, httpContext, formValue); + }; + } + + static async Task<(object? FormValue, bool Successful)> TryReadFormAsync( + HttpContext httpContext, + string parameterTypeName, + string parameterName, + bool throwOnBadRequest) + { + object? formValue = null; + var feature = httpContext.Features.Get(); + + if (feature?.CanHaveBody == true) + { + if (!httpContext.Request.HasFormContentType) + { + Log.UnexpectedNonFormContentType(httpContext, httpContext.Request.ContentType, throwOnBadRequest); + httpContext.Response.StatusCode = StatusCodes.Status415UnsupportedMediaType; + return (null, false); + } + + ThrowIfRequestIsAuthenticated(httpContext); + + try + { + formValue = await httpContext.Request.ReadFormAsync(); + } + catch (IOException ex) + { + Log.RequestBodyIOException(httpContext, ex); + return (null, false); + } + catch (InvalidDataException ex) + { + Log.InvalidFormRequestBody(httpContext, parameterTypeName, parameterName, ex, throwOnBadRequest); + httpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + return (null, false); + } + } + + return (formValue, true); + } + + static void ThrowIfRequestIsAuthenticated(HttpContext httpContext) + { + if (httpContext.Connection.ClientCertificate is not null) + { + throw new BadHttpRequestException( + "Support for binding parameters from an HTTP request's form is not currently supported " + + "if the request is associated with a client certificate. Use of an HTTP request form is " + + "not currently secure for HTTP requests in scenarios which require authentication."); + } + + if (!StringValues.IsNullOrEmpty(httpContext.Request.Headers.Authorization)) + { + throw new BadHttpRequestException( + "Support for binding parameters from an HTTP request's form is not currently supported " + + "if the request contains an \"Authorization\" HTTP request header. Use of an HTTP request form is " + + "not currently secure for HTTP requests in scenarios which require authentication."); + } + + if (!StringValues.IsNullOrEmpty(httpContext.Request.Headers.Cookie)) + { + throw new BadHttpRequestException( + "Support for binding parameters from an HTTP request's form is not currently supported " + + "if the request contains a \"Cookie\" HTTP request header. Use of an HTTP request form is " + + "not currently secure for HTTP requests in scenarios which require authentication."); + } + } + } + + private static Expression GetValueFromProperty(Expression sourceExpression, string key, Type? returnType = null) { var itemProperty = sourceExpression.Type.GetProperty("Item"); var indexArguments = new[] { Expression.Constant(key) }; var indexExpression = Expression.MakeIndex(sourceExpression, itemProperty, indexArguments); - return Expression.Convert(indexExpression, typeof(string)); + return Expression.Convert(indexExpression, returnType ?? typeof(string)); } private static Expression BindParameterFromService(ParameterInfo parameter, FactoryContext factoryContext) @@ -674,50 +875,7 @@ private static Expression BindParameterFromValue(ParameterInfo parameter, Expres if (parameter.ParameterType == typeof(string)) { - if (!isOptional) - { - // The following is produced if the parameter is required: - // - // tempSourceString = httpContext.RouteValue["param1"] ?? httpContext.Query["param1"]; - // if (tempSourceString == null) - // { - // wasParamCheckFailure = true; - // Log.RequiredParameterNotProvided(httpContext, "Int32", "param1"); - // } - var checkRequiredStringParameterBlock = Expression.Block( - Expression.Assign(argument, valueExpression), - Expression.IfThen(Expression.Equal(argument, Expression.Constant(null)), - Expression.Block( - Expression.Assign(WasParamCheckFailureExpr, Expression.Constant(true)), - Expression.Call(LogRequiredParameterNotProvidedMethod, - HttpContextExpr, parameterTypeNameConstant, parameterNameConstant, sourceConstant, - Expression.Constant(factoryContext.ThrowOnBadRequest)) - ) - ) - ); - - factoryContext.ExtraLocals.Add(argument); - factoryContext.ParamCheckExpressions.Add(checkRequiredStringParameterBlock); - return argument; - } - - // Allow nullable parameters that don't have a default value - var nullability = factoryContext.NullabilityContext.Create(parameter); - if (nullability.ReadState != NullabilityState.NotNull && !parameter.HasDefaultValue) - { - return valueExpression; - } - - // The following is produced if the parameter is optional. Note that we convert the - // default value to the target ParameterType to address scenarios where the user is - // is setting null as the default value in a context where nullability is disabled. - // - // param1_local = httpContext.RouteValue["param1"] ?? httpContext.Query["param1"]; - // param1_local != null ? param1_local : Convert(null, Int32) - return Expression.Block( - Expression.Condition(Expression.NotEqual(valueExpression, Expression.Constant(null)), - valueExpression, - Expression.Convert(Expression.Constant(parameter.DefaultValue), parameter.ParameterType))); + return BindParameterFromExpression(parameter, valueExpression, factoryContext, source); } factoryContext.UsingTempSourceString = true; @@ -835,6 +993,66 @@ private static Expression BindParameterFromValue(ParameterInfo parameter, Expres return argument; } + private static Expression BindParameterFromExpression( + ParameterInfo parameter, + Expression valueExpression, + FactoryContext factoryContext, + string source) + { + var nullability = factoryContext.NullabilityContext.Create(parameter); + var isOptional = IsOptionalParameter(parameter, factoryContext); + + var argument = Expression.Variable(parameter.ParameterType, $"{parameter.Name}_local"); + + var parameterTypeNameConstant = Expression.Constant(TypeNameHelper.GetTypeDisplayName(parameter.ParameterType, fullName: false)); + var parameterNameConstant = Expression.Constant(parameter.Name); + var sourceConstant = Expression.Constant(source); + + if (!isOptional) + { + // The following is produced if the parameter is required: + // + // argument = value["param1"]; + // if (argument == null) + // { + // wasParamCheckFailure = true; + // Log.RequiredParameterNotProvided(httpContext, "TypeOfValue", "param1"); + // } + var checkRequiredStringParameterBlock = Expression.Block( + Expression.Assign(argument, valueExpression), + Expression.IfThen(Expression.Equal(argument, Expression.Constant(null)), + Expression.Block( + Expression.Assign(WasParamCheckFailureExpr, Expression.Constant(true)), + Expression.Call(LogRequiredParameterNotProvidedMethod, + HttpContextExpr, parameterTypeNameConstant, parameterNameConstant, sourceConstant, + Expression.Constant(factoryContext.ThrowOnBadRequest)) + ) + ) + ); + + factoryContext.ExtraLocals.Add(argument); + factoryContext.ParamCheckExpressions.Add(checkRequiredStringParameterBlock); + return argument; + } + + // Allow nullable parameters that don't have a default value + if (nullability.ReadState != NullabilityState.NotNull && !parameter.HasDefaultValue) + { + return valueExpression; + } + + // The following is produced if the parameter is optional. Note that we convert the + // default value to the target ParameterType to address scenarios where the user is + // is setting null as the default value in a context where nullability is disabled. + // + // param1_local = httpContext.RouteValue["param1"] ?? httpContext.Query["param1"]; + // param1_local != null ? param1_local : Convert(null, Int32) + return Expression.Block( + Expression.Condition(Expression.NotEqual(valueExpression, Expression.Constant(null)), + valueExpression, + Expression.Convert(Expression.Constant(parameter.DefaultValue), parameter.ParameterType))); + } + private static Expression BindParameterFromProperty(ParameterInfo parameter, MemberExpression property, string key, FactoryContext factoryContext, string source) => BindParameterFromValue(parameter, GetValueFromProperty(property, key), factoryContext, source); @@ -889,6 +1107,54 @@ private static Expression BindParameterFromBindAsync(ParameterInfo parameter, Fa return Expression.Convert(boundValueExpr, parameter.ParameterType); } + private static Expression BindParameterFromFormFiles( + ParameterInfo parameter, + FactoryContext factoryContext) + { + if (factoryContext.FirstFormRequestBodyParameter is null) + { + factoryContext.FirstFormRequestBodyParameter = parameter; + } + + factoryContext.TrackedParameters.Add(parameter.Name!, RequestDelegateFactoryConstants.FormFileParameter); + + // Do not duplicate the metadata if there are multiple form parameters + if (!factoryContext.ReadForm) + { + factoryContext.Metadata.Add(new AcceptsMetadata(parameter.ParameterType, factoryContext.AllowEmptyRequestBody, FormFileContentType)); + } + + factoryContext.ReadForm = true; + + return BindParameterFromExpression(parameter, FormFilesExpr, factoryContext, "body"); + } + + private static Expression BindParameterFromFormFile( + ParameterInfo parameter, + string key, + FactoryContext factoryContext, + string trackedParameterSource) + { + if (factoryContext.FirstFormRequestBodyParameter is null) + { + factoryContext.FirstFormRequestBodyParameter = parameter; + } + + factoryContext.TrackedParameters.Add(key, trackedParameterSource); + + // Do not duplicate the metadata if there are multiple form parameters + if (!factoryContext.ReadForm) + { + factoryContext.Metadata.Add(new AcceptsMetadata(parameter.ParameterType, factoryContext.AllowEmptyRequestBody, FormFileContentType)); + } + + factoryContext.ReadForm = true; + + var valueExpression = GetValueFromProperty(FormFilesExpr, key, typeof(IFormFile)); + + return BindParameterFromExpression(parameter, valueExpression, factoryContext, "form file"); + } + private static Expression BindParameterFromBody(ParameterInfo parameter, bool allowEmpty, FactoryContext factoryContext) { if (factoryContext.JsonRequestBodyParameter is not null) @@ -1194,6 +1460,9 @@ private class FactoryContext public List Metadata { get; } = new(); public NullabilityInfoContext NullabilityContext { get; } = new(); + + public bool ReadForm { get; set; } + public ParameterInfo? FirstFormRequestBodyParameter { get; set; } } private static class RequestDelegateFactoryConstants @@ -1203,11 +1472,13 @@ private static class RequestDelegateFactoryConstants public const string HeaderAttribute = "Header (Attribute)"; public const string BodyAttribute = "Body (Attribute)"; public const string ServiceAttribute = "Service (Attribute)"; + public const string FormFileAttribute = "Form File (Attribute)"; public const string RouteParameter = "Route (Inferred)"; public const string QueryStringParameter = "Query String (Inferred)"; public const string ServiceParameter = "Services (Inferred)"; public const string BodyParameter = "Body (Inferred)"; public const string RouteOrQueryStringParameter = "Route or Query String (Inferred)"; + public const string FormFileParameter = "Form File (Inferred)"; } private static partial class Log @@ -1221,12 +1492,18 @@ private static partial class Log private const string RequiredParameterNotProvidedLogMessage = @"Required parameter ""{ParameterType} {ParameterName}"" was not provided from {Source}."; private const string RequiredParameterNotProvidedExceptionMessage = @"Required parameter ""{0} {1}"" was not provided from {2}."; - private const string UnexpectedContentTypeLogMessage = @"Expected a supported JSON media type but got ""{ContentType}""."; - private const string UnexpectedContentTypeExceptionMessage = @"Expected a supported JSON media type but got ""{0}""."; + private const string UnexpectedJsonContentTypeLogMessage = @"Expected a supported JSON media type but got ""{ContentType}""."; + private const string UnexpectedJsonContentTypeExceptionMessage = @"Expected a supported JSON media type but got ""{0}""."; private const string ImplicitBodyNotProvidedLogMessage = @"Implicit body inferred for parameter ""{ParameterName}"" but no body was provided. Did you mean to use a Service instead?"; private const string ImplicitBodyNotProvidedExceptionMessage = @"Implicit body inferred for parameter ""{0}"" but no body was provided. Did you mean to use a Service instead?"; + private const string InvalidFormRequestBodyMessage = @"Failed to read parameter ""{ParameterType} {ParameterName}"" from the request body as form."; + private const string InvalidFormRequestBodyExceptionMessage = @"Failed to read parameter ""{0} {1}"" from the request body as form."; + + private const string UnexpectedFormContentTypeLogMessage = @"Expected a supported form media type but got ""{ContentType}""."; + private const string UnexpectedFormContentTypeExceptionMessage = @"Expected a supported form media type but got ""{0}""."; + // This doesn't take a shouldThrow parameter because an IOException indicates an aborted request rather than a "bad" request so // a BadHttpRequestException feels wrong. The client shouldn't be able to read the Developer Exception Page at any rate. public static void RequestBodyIOException(HttpContext httpContext, IOException exception) @@ -1291,19 +1568,47 @@ public static void ImplicitBodyNotProvided(HttpContext httpContext, string param [LoggerMessage(5, LogLevel.Debug, ImplicitBodyNotProvidedLogMessage, EventName = "ImplicitBodyNotProvided")] private static partial void ImplicitBodyNotProvided(ILogger logger, string parameterName); - public static void UnexpectedContentType(HttpContext httpContext, string? contentType, bool shouldThrow) + public static void UnexpectedJsonContentType(HttpContext httpContext, string? contentType, bool shouldThrow) + { + if (shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, UnexpectedJsonContentTypeExceptionMessage, contentType); + throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); + } + + UnexpectedJsonContentType(GetLogger(httpContext), contentType ?? "(none)"); + } + + [LoggerMessage(6, LogLevel.Debug, UnexpectedJsonContentTypeLogMessage, EventName = "UnexpectedContentType")] + private static partial void UnexpectedJsonContentType(ILogger logger, string contentType); + + public static void UnexpectedNonFormContentType(HttpContext httpContext, string? contentType, bool shouldThrow) { if (shouldThrow) { - var message = string.Format(CultureInfo.InvariantCulture, UnexpectedContentTypeExceptionMessage, contentType); + var message = string.Format(CultureInfo.InvariantCulture, UnexpectedFormContentTypeExceptionMessage, contentType); throw new BadHttpRequestException(message, StatusCodes.Status415UnsupportedMediaType); } - UnexpectedContentType(GetLogger(httpContext), contentType ?? "(none)"); + UnexpectedNonFormContentType(GetLogger(httpContext), contentType ?? "(none)"); } - [LoggerMessage(6, LogLevel.Debug, UnexpectedContentTypeLogMessage, EventName = "UnexpectedContentType")] - private static partial void UnexpectedContentType(ILogger logger, string contentType); + [LoggerMessage(7, LogLevel.Debug, UnexpectedFormContentTypeLogMessage, EventName = "UnexpectedNonFormContentType")] + private static partial void UnexpectedNonFormContentType(ILogger logger, string contentType); + + public static void InvalidFormRequestBody(HttpContext httpContext, string parameterTypeName, string parameterName, Exception exception, bool shouldThrow) + { + if (shouldThrow) + { + var message = string.Format(CultureInfo.InvariantCulture, InvalidFormRequestBodyExceptionMessage, parameterTypeName, parameterName); + throw new BadHttpRequestException(message, exception); + } + + InvalidFormRequestBody(GetLogger(httpContext), parameterTypeName, parameterName, exception); + } + + [LoggerMessage(8, LogLevel.Debug, InvalidFormRequestBodyMessage, EventName = "InvalidFormRequestBody")] + private static partial void InvalidFormRequestBody(ILogger logger, string parameterType, string parameterName, Exception exception); private static ILogger GetLogger(HttpContext httpContext) { @@ -1352,10 +1657,8 @@ private static string BuildErrorMessageForMultipleBodyParameters(FactoryContext errorMessage.AppendLine(FormattableString.Invariant($"{"Parameter",-20}| {"Source",-30}")); errorMessage.AppendLine("---------------------------------------------------------------------------------"); - foreach (var kv in factoryContext.TrackedParameters) - { - errorMessage.AppendLine(FormattableString.Invariant($"{kv.Key,-19} | {kv.Value,-15}")); - } + FormatTrackedParameters(factoryContext, errorMessage); + errorMessage.AppendLine().AppendLine(); errorMessage.AppendLine("Did you mean to register the \"UNKNOWN\" parameters as a Service?") .AppendLine(); @@ -1371,13 +1674,33 @@ private static string BuildErrorMessageForInferredBodyParameter(FactoryContext f errorMessage.AppendLine(FormattableString.Invariant($"{"Parameter",-20}| {"Source",-30}")); errorMessage.AppendLine("---------------------------------------------------------------------------------"); - foreach (var kv in factoryContext.TrackedParameters) - { - errorMessage.AppendLine(FormattableString.Invariant($"{kv.Key,-19} | {kv.Value,-15}")); - } + FormatTrackedParameters(factoryContext, errorMessage); + errorMessage.AppendLine().AppendLine(); errorMessage.AppendLine("Did you mean to register the \"Body (Inferred)\" parameter(s) as a Service or apply the [FromService] or [FromBody] attribute?") .AppendLine(); return errorMessage.ToString(); } + + private static string BuildErrorMessageForFormAndJsonBodyParameters(FactoryContext factoryContext) + { + var errorMessage = new StringBuilder(); + errorMessage.AppendLine("An action cannot use both form and JSON body parameters."); + errorMessage.AppendLine("Below is the list of parameters that we found: "); + errorMessage.AppendLine(); + errorMessage.AppendLine(FormattableString.Invariant($"{"Parameter",-20}| {"Source",-30}")); + errorMessage.AppendLine("---------------------------------------------------------------------------------"); + + FormatTrackedParameters(factoryContext, errorMessage); + + return errorMessage.ToString(); + } + + private static void FormatTrackedParameters(FactoryContext factoryContext, StringBuilder errorMessage) + { + foreach (var kv in factoryContext.TrackedParameters) + { + errorMessage.AppendLine(FormattableString.Invariant($"{kv.Key,-19} | {kv.Value,-15}")); + } + } } diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs index 7640eb1c556e..b659ba4c152c 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs @@ -7,11 +7,13 @@ using System.IO.Pipelines; using System.Linq.Expressions; using System.Net; +using System.Net.Http; using System.Net.Sockets; using System.Numerics; using System.Reflection; using System.Reflection.Metadata; using System.Security.Claims; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -3096,6 +3098,711 @@ public async Task RequestDelegateCanProcessTimeOnlyValues(Delegate @delegate, st Assert.Equal(expectedResponse, decodedResponseBody); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RequestDelegateLogsIOExceptionsForFormAsDebugDoesNotAbortAndNeverThrows(bool throwOnBadRequests) + { + var invoked = false; + + void TestAction(IFormFile file) + { + invoked = true; + } + + var ioException = new IOException(); + + var httpContext = CreateHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"; + httpContext.Request.Headers["Content-Length"] = "1"; + httpContext.Request.Body = new ExceptionThrowingRequestBodyStream(ioException); + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction, new() { ThrowOnBadRequest = throwOnBadRequests }); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.False(invoked); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + + var logMessage = Assert.Single(TestSink.Writes); + Assert.Equal(new EventId(1, "RequestBodyIOException"), logMessage.EventId); + Assert.Equal(LogLevel.Debug, logMessage.LogLevel); + Assert.Equal("Reading the request body failed with an IOException.", logMessage.Message); + Assert.Same(ioException, logMessage.Exception); + } + + [Fact] + public async Task RequestDelegateLogsMalformedFormAsDebugAndSets400Response() + { + var invoked = false; + + void TestAction(IFormFile file) + { + invoked = true; + } + + var httpContext = CreateHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"; + httpContext.Request.Headers["Content-Length"] = "2049"; + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(new string('x', 2049))); + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.False(invoked); + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(400, httpContext.Response.StatusCode); + Assert.False(httpContext.Response.HasStarted); + + var logMessage = Assert.Single(TestSink.Writes); + Assert.Equal(new EventId(8, "InvalidFormRequestBody"), logMessage.EventId); + Assert.Equal(LogLevel.Debug, logMessage.LogLevel); + Assert.Equal(@"Failed to read parameter ""IFormFile file"" from the request body as form.", logMessage.Message); + Assert.IsType(logMessage.Exception); + } + + [Fact] + public async Task RequestDelegateThrowsForMalformedFormIfThrowOnBadRequest() + { + var invoked = false; + + void TestAction(IFormFile file) + { + invoked = true; + } + + var httpContext = CreateHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/x-www-form-urlencoded"; + httpContext.Request.Headers["Content-Length"] = "2049"; + httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(new string('x', 2049))); + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction, new() { ThrowOnBadRequest = true }); + var requestDelegate = factoryResult.RequestDelegate; + + var badHttpRequestException = await Assert.ThrowsAsync(() => requestDelegate(httpContext)); + + Assert.False(invoked); + + // The httpContext should be untouched. + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.False(httpContext.Response.HasStarted); + + // We don't log bad requests when we throw. + Assert.Empty(TestSink.Writes); + + Assert.Equal(@"Failed to read parameter ""IFormFile file"" from the request body as form.", badHttpRequestException.Message); + Assert.Equal(400, badHttpRequestException.StatusCode); + Assert.IsType(badHttpRequestException.InnerException); + } + + [Fact] + public void BuildRequestDelegateThrowsInvalidOperationExceptionBodyAndFormFileParameters() + { + void TestFormFileAndJson(IFormFile value1, Todo value2) { } + void TestFormFilesAndJson(IFormFile value1, IFormFile value2, Todo value3) { } + void TestFormFileCollectionAndJson(IFormFileCollection value1, Todo value2) { } + void TestFormFileAndJsonWithAttribute(IFormFile value1, [FromBody] int value2) { } + void TestJsonAndFormFile(Todo value1, IFormFile value2) { } + void TestJsonAndFormFiles(Todo value1, IFormFile value2, IFormFile value3) { } + void TestJsonAndFormFileCollection(Todo value1, IFormFileCollection value2) { } + void TestJsonAndFormFileWithAttribute(Todo value1, [FromForm] IFormFile value2) { } + + Assert.Throws(() => RequestDelegateFactory.Create(TestFormFileAndJson)); + Assert.Throws(() => RequestDelegateFactory.Create(TestFormFilesAndJson)); + Assert.Throws(() => RequestDelegateFactory.Create(TestFormFileAndJsonWithAttribute)); + Assert.Throws(() => RequestDelegateFactory.Create(TestFormFileCollectionAndJson)); + Assert.Throws(() => RequestDelegateFactory.Create(TestJsonAndFormFile)); + Assert.Throws(() => RequestDelegateFactory.Create(TestJsonAndFormFiles)); + Assert.Throws(() => RequestDelegateFactory.Create(TestJsonAndFormFileCollection)); + Assert.Throws(() => RequestDelegateFactory.Create(TestJsonAndFormFileWithAttribute)); + } + + [Fact] + public async Task RequestDelegatePopulatesFromIFormFileCollectionParameter() + { + IFormFileCollection? formFilesArgument = null; + + void TestAction(IFormFileCollection formFiles) + { + formFilesArgument = formFiles; + } + + var fileContent = new StringContent("hello", Encoding.UTF8, "application/octet-stream"); + var form = new MultipartFormDataContent("some-boundary"); + form.Add(fileContent, "file", "file.txt"); + + var stream = new MemoryStream(); + await form.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = "multipart/form-data;boundary=some-boundary"; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Request.Form.Files, formFilesArgument); + Assert.NotNull(formFilesArgument!["file"]); + + var allAcceptsMetadata = factoryResult.EndpointMetadata.OfType(); + var acceptsMetadata = Assert.Single(allAcceptsMetadata); + + Assert.NotNull(acceptsMetadata); + Assert.Equal(new[] { "multipart/form-data" }, acceptsMetadata.ContentTypes); + } + + [Fact] + public async Task RequestDelegatePopulatesFromIFormFileCollectionParameterWithAttribute() + { + IFormFileCollection? formFilesArgument = null; + + void TestAction([FromForm] IFormFileCollection formFiles) + { + formFilesArgument = formFiles; + } + + var fileContent = new StringContent("hello", Encoding.UTF8, "application/octet-stream"); + var form = new MultipartFormDataContent("some-boundary"); + form.Add(fileContent, "file", "file.txt"); + + var stream = new MemoryStream(); + await form.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = "multipart/form-data;boundary=some-boundary"; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Request.Form.Files, formFilesArgument); + Assert.NotNull(formFilesArgument!["file"]); + + var allAcceptsMetadata = factoryResult.EndpointMetadata.OfType(); + var acceptsMetadata = Assert.Single(allAcceptsMetadata); + + Assert.NotNull(acceptsMetadata); + Assert.Equal(new[] { "multipart/form-data" }, acceptsMetadata.ContentTypes); + } + + [Fact] + public void CreateThrowsNotSupportedExceptionIfIFormFileCollectionHasMetadataParameterName() + { + IFormFileCollection? formFilesArgument = null; + + void TestAction([FromForm(Name = "foo")] IFormFileCollection formFiles) + { + formFilesArgument = formFiles; + } + + var nse = Assert.Throws(() => RequestDelegateFactory.Create(TestAction)); + Assert.Equal("Assigning a value to the IFromFormMetadata.Name property is not supported for parameters of type IFormFileCollection.", nse.Message); + } + + [Fact] + public void CreateThrowsNotSupportedExceptionIfFromFormParameterIsNotIFormFileCollectionOrIFormFile() + { + void TestActionBool([FromForm] bool value) { }; + void TestActionInt([FromForm] int value) { }; + void TestActionObject([FromForm] object value) { }; + void TestActionString([FromForm] string value) { }; + void TestActionCancellationToken([FromForm] CancellationToken value) { }; + void TestActionClaimsPrincipal([FromForm] ClaimsPrincipal value) { }; + void TestActionHttpContext([FromForm] HttpContext value) { }; + void TestActionIFormCollection([FromForm] IFormCollection value) { }; + + AssertNotSupportedExceptionThrown(TestActionBool); + AssertNotSupportedExceptionThrown(TestActionInt); + AssertNotSupportedExceptionThrown(TestActionObject); + AssertNotSupportedExceptionThrown(TestActionString); + AssertNotSupportedExceptionThrown(TestActionCancellationToken); + AssertNotSupportedExceptionThrown(TestActionClaimsPrincipal); + AssertNotSupportedExceptionThrown(TestActionHttpContext); + AssertNotSupportedExceptionThrown(TestActionIFormCollection); + + static void AssertNotSupportedExceptionThrown(Delegate handler) + { + var nse = Assert.Throws(() => RequestDelegateFactory.Create(handler)); + Assert.Equal("IFromFormMetadata is only supported for parameters of type IFormFileCollection and IFormFile.", nse.Message); + } + } + + [Fact] + public async Task RequestDelegatePopulatesFromIFormFileParameter() + { + IFormFile? fileArgument = null; + + void TestAction(IFormFile file) + { + fileArgument = file; + } + + var fileContent = new StringContent("hello", Encoding.UTF8, "application/octet-stream"); + var form = new MultipartFormDataContent("some-boundary"); + form.Add(fileContent, "file", "file.txt"); + + var stream = new MemoryStream(); + await form.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = "multipart/form-data;boundary=some-boundary"; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Request.Form.Files["file"], fileArgument); + Assert.Equal("file.txt", fileArgument!.FileName); + Assert.Equal("file", fileArgument.Name); + } + + [Fact] + public async Task RequestDelegatePopulatesFromOptionalIFormFileParameter() + { + IFormFile? fileArgument = null; + + void TestAction(IFormFile? file) + { + fileArgument = file; + } + + var fileContent = new StringContent("hello", Encoding.UTF8, "application/octet-stream"); + var form = new MultipartFormDataContent("some-boundary"); + form.Add(fileContent, "file", "file.txt"); + + var stream = new MemoryStream(); + await form.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = "multipart/form-data;boundary=some-boundary"; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Request.Form.Files["file"], fileArgument); + Assert.Equal("file.txt", fileArgument!.FileName); + Assert.Equal("file", fileArgument.Name); + } + + [Fact] + public async Task RequestDelegatePopulatesFromMultipleRequiredIFormFileParameters() + { + IFormFile? file1Argument = null; + IFormFile? file2Argument = null; + + void TestAction(IFormFile file1, IFormFile file2) + { + file1Argument = file1; + file2Argument = file2; + } + + var fileContent1 = new StringContent("hello", Encoding.UTF8, "application/octet-stream"); + var fileContent2 = new StringContent("there", Encoding.UTF8, "application/octet-stream"); + var form = new MultipartFormDataContent("some-boundary"); + form.Add(fileContent1, "file1", "file1.txt"); + form.Add(fileContent2, "file2", "file2.txt"); + + var stream = new MemoryStream(); + await form.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = "multipart/form-data;boundary=some-boundary"; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Request.Form.Files["file1"], file1Argument); + Assert.Equal("file1.txt", file1Argument!.FileName); + Assert.Equal("file1", file1Argument.Name); + + Assert.Equal(httpContext.Request.Form.Files["file2"], file2Argument); + Assert.Equal("file2.txt", file2Argument!.FileName); + Assert.Equal("file2", file2Argument.Name); + } + + [Fact] + public async Task RequestDelegatePopulatesFromOptionalMissingIFormFileParameter() + { + IFormFile? file1Argument = null; + IFormFile? file2Argument = null; + + void TestAction(IFormFile? file1, IFormFile? file2) + { + file1Argument = file1; + file2Argument = file2; + } + + var fileContent = new StringContent("hello", Encoding.UTF8, "application/octet-stream"); + var form = new MultipartFormDataContent("some-boundary"); + form.Add(fileContent, "file1", "file.txt"); + + var stream = new MemoryStream(); + await form.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = "multipart/form-data;boundary=some-boundary"; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Request.Form.Files["file1"], file1Argument); + Assert.NotNull(file1Argument); + + Assert.Equal(httpContext.Request.Form.Files["file2"], file2Argument); + Assert.Null(file2Argument); + + var allAcceptsMetadata = factoryResult.EndpointMetadata.OfType(); + var acceptsMetadata = Assert.Single(allAcceptsMetadata); + + Assert.NotNull(acceptsMetadata); + Assert.Equal(new[] { "multipart/form-data" }, acceptsMetadata.ContentTypes); + } + + [Fact] + public async Task RequestDelegatePopulatesFromIFormFileParameterWithMetadata() + { + IFormFile? fileArgument = null; + + void TestAction([FromForm(Name = "my_file")] IFormFile file) + { + fileArgument = file; + } + + var fileContent = new StringContent("hello", Encoding.UTF8, "application/octet-stream"); + var form = new MultipartFormDataContent("some-boundary"); + form.Add(fileContent, "my_file", "file.txt"); + + var stream = new MemoryStream(); + await form.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = "multipart/form-data;boundary=some-boundary"; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Request.Form.Files["my_file"], fileArgument); + Assert.Equal("file.txt", fileArgument!.FileName); + Assert.Equal("my_file", fileArgument.Name); + } + + [Fact] + public async Task RequestDelegatePopulatesFromIFormFileAndBoundParameter() + { + IFormFile? fileArgument = null; + TraceIdentifier traceIdArgument = default; + + void TestAction(IFormFile? file, TraceIdentifier traceId) + { + fileArgument = file; + traceIdArgument = traceId; + } + + var fileContent = new StringContent("hello", Encoding.UTF8, "application/octet-stream"); + var form = new MultipartFormDataContent("some-boundary"); + form.Add(fileContent, "file", "file.txt"); + + var stream = new MemoryStream(); + await form.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = "multipart/form-data;boundary=some-boundary"; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + httpContext.TraceIdentifier = "my-trace-id"; + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Request.Form.Files["file"], fileArgument); + Assert.Equal("file.txt", fileArgument!.FileName); + Assert.Equal("file", fileArgument.Name); + + Assert.Equal("my-trace-id", traceIdArgument.Id); + } + + private readonly struct TraceIdentifier + { + private TraceIdentifier(string id) + { + Id = id; + } + + public string Id { get; } + + public static implicit operator string(TraceIdentifier value) => value.Id; + + public static ValueTask BindAsync(HttpContext context) + { + return ValueTask.FromResult(new TraceIdentifier(context.TraceIdentifier)); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RequestDelegateRejectsNonFormContent(bool shouldThrow) + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Content-Type"] = "application/xml"; + httpContext.Request.Headers["Content-Length"] = "1"; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var serviceCollection = new ServiceCollection(); + serviceCollection.AddSingleton(LoggerFactory); + httpContext.RequestServices = serviceCollection.BuildServiceProvider(); + + var factoryResult = RequestDelegateFactory.Create((HttpContext context, IFormFile file) => + { + }, new RequestDelegateFactoryOptions() { ThrowOnBadRequest = shouldThrow }); + var requestDelegate = factoryResult.RequestDelegate; + + var request = requestDelegate(httpContext); + + if (shouldThrow) + { + var ex = await Assert.ThrowsAsync(() => request); + Assert.Equal("Expected a supported form media type but got \"application/xml\".", ex.Message); + Assert.Equal(StatusCodes.Status415UnsupportedMediaType, ex.StatusCode); + } + else + { + await request; + + Assert.Equal(415, httpContext.Response.StatusCode); + var logMessage = Assert.Single(TestSink.Writes); + Assert.Equal(new EventId(7, "UnexpectedContentType"), logMessage.EventId); + Assert.Equal(LogLevel.Debug, logMessage.LogLevel); + Assert.Equal("Expected a supported form media type but got \"application/xml\".", logMessage.Message); + } + } + + [Fact] + public async Task RequestDelegateSets400ResponseIfRequiredFileNotSpecified() + { + var invoked = false; + + void TestAction(IFormFile file) + { + invoked = true; + } + + var fileContent = new StringContent("hello", Encoding.UTF8, "application/octet-stream"); + var form = new MultipartFormDataContent("some-boundary"); + form.Add(fileContent, "some-other-file", "file.txt"); + + var stream = new MemoryStream(); + await form.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = "multipart/form-data;boundary=some-boundary"; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.False(invoked); + Assert.Equal(400, httpContext.Response.StatusCode); + } + + [Fact] + public async Task RequestDelegatePopulatesFromBothFormFileCollectionAndFormFileParameters() + { + IFormFileCollection? formFilesArgument = null; + IFormFile? fileArgument = null; + + void TestAction(IFormFileCollection formFiles, IFormFile file) + { + formFilesArgument = formFiles; + fileArgument = file; + } + + var fileContent = new StringContent("hello", Encoding.UTF8, "application/octet-stream"); + var form = new MultipartFormDataContent("some-boundary"); + form.Add(fileContent, "file", "file.txt"); + + var stream = new MemoryStream(); + await form.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = "multipart/form-data;boundary=some-boundary"; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Request.Form.Files, formFilesArgument); + Assert.NotNull(formFilesArgument!["file"]); + + Assert.Equal(httpContext.Request.Form.Files["file"], fileArgument); + Assert.Equal("file.txt", fileArgument!.FileName); + Assert.Equal("file", fileArgument.Name); + + var allAcceptsMetadata = factoryResult.EndpointMetadata.OfType(); + var acceptsMetadata = Assert.Single(allAcceptsMetadata); + + Assert.NotNull(acceptsMetadata); + Assert.Equal(new[] { "multipart/form-data" }, acceptsMetadata.ContentTypes); + } + + [Theory] + [InlineData("Authorization", "bearer my-token", "Support for binding parameters from an HTTP request's form is not currently supported if the request contains an \"Authorization\" HTTP request header. Use of an HTTP request form is not currently secure for HTTP requests in scenarios which require authentication.")] + [InlineData("Cookie", ".AspNetCore.Auth=abc123", "Support for binding parameters from an HTTP request's form is not currently supported if the request contains a \"Cookie\" HTTP request header. Use of an HTTP request form is not currently secure for HTTP requests in scenarios which require authentication.")] + public async Task RequestDelegateThrowsIfRequestUsingFormContainsSecureHeader( + string headerName, + string headerValue, + string expectedMessage) + { + var invoked = false; + + void TestAction(IFormFile file) + { + invoked = true; + } + + var fileContent = new StringContent("hello", Encoding.UTF8, "application/octet-stream"); + var form = new MultipartFormDataContent("some-boundary"); + form.Add(fileContent, "file", "file.txt"); + + var stream = new MemoryStream(); + await form.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers[headerName] = headerValue; + httpContext.Request.Headers["Content-Type"] = "multipart/form-data;boundary=some-boundary"; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + var badHttpRequestException = await Assert.ThrowsAsync(() => requestDelegate(httpContext)); + + Assert.False(invoked); + + // The httpContext should be untouched. + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.False(httpContext.Response.HasStarted); + + // We don't log bad requests when we throw. + Assert.Empty(TestSink.Writes); + + Assert.Equal(expectedMessage, badHttpRequestException.Message); + Assert.Equal(400, badHttpRequestException.StatusCode); + } + + [Fact] + public async Task RequestDelegateThrowsIfRequestUsingFormHasClientCertificate() + { + var invoked = false; + + void TestAction(IFormFile file) + { + invoked = true; + } + + var fileContent = new StringContent("hello", Encoding.UTF8, "application/octet-stream"); + var form = new MultipartFormDataContent("some-boundary"); + form.Add(fileContent, "file", "file.txt"); + + var stream = new MemoryStream(); + await form.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = "multipart/form-data;boundary=some-boundary"; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + +#pragma warning disable SYSLIB0026 // Type or member is obsolete + var clientCertificate = new X509Certificate2(); +#pragma warning restore SYSLIB0026 // Type or member is obsolete + + httpContext.Features.Set(new TlsConnectionFeature(clientCertificate)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + var badHttpRequestException = await Assert.ThrowsAsync(() => requestDelegate(httpContext)); + + Assert.False(invoked); + + // The httpContext should be untouched. + Assert.False(httpContext.RequestAborted.IsCancellationRequested); + Assert.Equal(200, httpContext.Response.StatusCode); + Assert.False(httpContext.Response.HasStarted); + + // We don't log bad requests when we throw. + Assert.Empty(TestSink.Writes); + + Assert.Equal("Support for binding parameters from an HTTP request's form is not currently supported if the request is associated with a client certificate. Use of an HTTP request form is not currently secure for HTTP requests in scenarios which require authentication.", badHttpRequestException.Message); + Assert.Equal(400, badHttpRequestException.StatusCode); + } + private DefaultHttpContext CreateHttpContext() { var responseFeature = new TestHttpResponseFeature(); @@ -3217,6 +3924,11 @@ private class FromBodyAttribute : Attribute, IFromBodyMetadata public bool AllowEmpty { get; set; } } + private class FromFormAttribute : Attribute, IFromFormMetadata + { + public string? Name { get; set; } + } + private class FromServiceAttribute : Attribute, IFromServiceMetadata { } @@ -3430,6 +4142,21 @@ public RequestBodyDetectionFeature(bool canHaveBody) public bool CanHaveBody { get; } } + + private class TlsConnectionFeature : ITlsConnectionFeature + { + public TlsConnectionFeature(X509Certificate2 clientCertificate) + { + ClientCertificate = clientCertificate; + } + + public X509Certificate2? ClientCertificate { get; set; } + + public Task GetClientCertificateAsync(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + } } internal static class TestExtensionResults diff --git a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs index 6c960cf77fa0..ed5eea1f602e 100644 --- a/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs +++ b/src/Mvc/Mvc.ApiExplorer/src/EndpointMetadataApiDescriptionProvider.cs @@ -92,12 +92,14 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string { DisplayName = routeEndpoint.DisplayName, RouteValues = - { - ["controller"] = controllerName, - }, + { + ["controller"] = controllerName, + }, }, }; + var hasBodyOrFormFileParameter = false; + foreach (var parameter in methodInfo.GetParameters()) { var parameterDescription = CreateApiParameterDescription(parameter, routeEndpoint.RoutePattern); @@ -108,23 +110,33 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string } apiDescription.ParameterDescriptions.Add(parameterDescription); + + hasBodyOrFormFileParameter |= + parameterDescription.Source == BindingSource.Body || + parameterDescription.Source == BindingSource.FormFile; } // Get IAcceptsMetadata. var acceptsMetadata = routeEndpoint.Metadata.GetMetadata(); if (acceptsMetadata is not null) { - var acceptsRequestType = acceptsMetadata.RequestType; - var isOptional = acceptsMetadata.IsOptional; - var parameterDescription = new ApiParameterDescription + // Add a default body parameter if there was no explicitly defined parameter associated with + // either the body or a form and the user explicity defined some metadata describing the + // content types the endpoint consumes (such as Accepts(...) or [Consumes(...)]). + if (!hasBodyOrFormFileParameter) { - Name = acceptsRequestType is not null ? acceptsRequestType.Name : typeof(void).Name, - ModelMetadata = CreateModelMetadata(acceptsRequestType ?? typeof(void)), - Source = BindingSource.Body, - Type = acceptsRequestType ?? typeof(void), - IsRequired = !isOptional, - }; - apiDescription.ParameterDescriptions.Add(parameterDescription); + var acceptsRequestType = acceptsMetadata.RequestType; + var isOptional = acceptsMetadata.IsOptional; + var parameterDescription = new ApiParameterDescription + { + Name = acceptsRequestType is not null ? acceptsRequestType.Name : typeof(void).Name, + ModelMetadata = CreateModelMetadata(acceptsRequestType ?? typeof(void)), + Source = BindingSource.Body, + Type = acceptsRequestType ?? typeof(void), + IsRequired = !isOptional, + }; + apiDescription.ParameterDescriptions.Add(parameterDescription); + } var supportedRequestFormats = apiDescription.SupportedRequestFormats; @@ -148,8 +160,7 @@ private ApiDescription CreateApiDescription(RouteEndpoint routeEndpoint, string var (source, name, allowEmpty, paramType) = GetBindingSourceAndName(parameter, pattern); // Services are ignored because they are not request parameters. - // We ignore/skip body parameter because the value will be retrieved from the IAcceptsMetadata. - if (source == BindingSource.Services || source == BindingSource.Body) + if (source == BindingSource.Services) { return null; } @@ -239,6 +250,10 @@ private static ParameterDescriptor CreateParameterDescriptor(ParameterInfo param { return (BindingSource.Body, parameter.Name ?? string.Empty, fromBodyAttribute.AllowEmpty, parameter.ParameterType); } + else if (attributes.OfType().FirstOrDefault() is { } fromFormAttribute) + { + return (BindingSource.FormFile, fromFormAttribute.Name ?? parameter.Name ?? string.Empty, false, parameter.ParameterType); + } else if (parameter.CustomAttributes.Any(a => typeof(IFromServiceMetadata).IsAssignableFrom(a.AttributeType)) || parameter.ParameterType == typeof(HttpContext) || parameter.ParameterType == typeof(HttpRequest) || @@ -265,6 +280,10 @@ private static ParameterDescriptor CreateParameterDescriptor(ParameterInfo param return (BindingSource.Query, parameter.Name ?? string.Empty, false, displayType); } } + else if (parameter.ParameterType == typeof(IFormFile) || parameter.ParameterType == typeof(IFormFileCollection)) + { + return (BindingSource.FormFile, parameter.Name ?? string.Empty, false, parameter.ParameterType); + } else { return (BindingSource.Body, parameter.Name ?? string.Empty, false, parameter.ParameterType); diff --git a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs index 8f25def7e576..06483a8b9fa3 100644 --- a/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs +++ b/src/Mvc/Mvc.ApiExplorer/test/EndpointMetadataApiDescriptionProviderTest.cs @@ -109,6 +109,8 @@ public void AddsMultipleRequestFormatsFromMetadataWithRequestTypeAndOptionalBody Assert.False(apiParameterDescription.IsRequired); } +#nullable enable + [Fact] public void AddsMultipleRequestFormatsFromMetadataWithRequiredBodyParameter() { @@ -124,6 +126,8 @@ public void AddsMultipleRequestFormatsFromMetadataWithRequiredBodyParameter() Assert.True(apiParameterDescription.IsRequired); } +#nullable disable + [Fact] public void AddsJsonResponseFormatWhenFromBodyInferred() { @@ -362,15 +366,19 @@ public void DoesNotAddFromServiceParameterAsService() } [Fact] - public void DoesNotAddFromBodyParameterInTheParameterDescription() + public void AddsBodyParameterInTheParameterDescription() { - static void AssertBodyParameter(ApiDescription apiDescription, Type expectedType) + static void AssertBodyParameter(ApiDescription apiDescription, string expectedName, Type expectedType) { - Assert.Empty(apiDescription.ParameterDescriptions); + var param = Assert.Single(apiDescription.ParameterDescriptions); + Assert.Equal(expectedName, param.Name); + Assert.Equal(expectedType, param.Type); + Assert.Equal(expectedType, param.ModelMetadata.ModelType); + Assert.Equal(BindingSource.Body, param.Source); } - AssertBodyParameter(GetApiDescription((InferredJsonClass foo) => { }), typeof(InferredJsonClass)); - AssertBodyParameter(GetApiDescription(([FromBody] int foo) => { }), typeof(int)); + AssertBodyParameter(GetApiDescription((InferredJsonClass foo) => { }), "foo", typeof(InferredJsonClass)); + AssertBodyParameter(GetApiDescription(([FromBody] int bar) => { }), "bar", typeof(int)); } [Fact] @@ -382,25 +390,38 @@ public void AddsDefaultValueFromParameters() Assert.Equal(42, param.DefaultValue); } +#nullable enable + [Fact] public void AddsMultipleParameters() { var apiDescription = GetApiDescription(([FromRoute] int foo, int bar, InferredJsonClass fromBody) => { }); - Assert.Equal(2, apiDescription.ParameterDescriptions.Count); + Assert.Equal(3, apiDescription.ParameterDescriptions.Count); var fooParam = apiDescription.ParameterDescriptions[0]; + Assert.Equal("foo", fooParam.Name); Assert.Equal(typeof(int), fooParam.Type); Assert.Equal(typeof(int), fooParam.ModelMetadata.ModelType); Assert.Equal(BindingSource.Path, fooParam.Source); Assert.True(fooParam.IsRequired); var barParam = apiDescription.ParameterDescriptions[1]; + Assert.Equal("bar", barParam.Name); Assert.Equal(typeof(int), barParam.Type); Assert.Equal(typeof(int), barParam.ModelMetadata.ModelType); Assert.Equal(BindingSource.Query, barParam.Source); Assert.True(barParam.IsRequired); + + var fromBodyParam = apiDescription.ParameterDescriptions[2]; + Assert.Equal("fromBody", fromBodyParam.Name); + Assert.Equal(typeof(InferredJsonClass), fromBodyParam.Type); + Assert.Equal(typeof(InferredJsonClass), fromBodyParam.ModelMetadata.ModelType); + Assert.Equal(BindingSource.Body, fromBodyParam.Source); + Assert.True(fromBodyParam.IsRequired); } +#nullable disable + [Fact] public void TestParameterIsRequired() { @@ -711,8 +732,8 @@ public void HandleAcceptsMetadataWithTypeParameter() var parameterDescriptions = context.Results.SelectMany(r => r.ParameterDescriptions); var bodyParameterDescription = parameterDescriptions.Single(); Assert.Equal(typeof(InferredJsonClass), bodyParameterDescription.Type); - Assert.Equal(typeof(InferredJsonClass).Name, bodyParameterDescription.Name); - Assert.True(bodyParameterDescription.IsRequired); + Assert.Equal("inferredJsonClass", bodyParameterDescription.Name); + Assert.False(bodyParameterDescription.IsRequired); } [Fact] @@ -774,7 +795,7 @@ public void HandleDefaultIAcceptsMetadataForRequiredBodyParameter() var parameterDescriptions = context.Results.SelectMany(r => r.ParameterDescriptions); var bodyParameterDescription = parameterDescriptions.Single(); Assert.Equal(typeof(InferredJsonClass), bodyParameterDescription.Type); - Assert.Equal(typeof(InferredJsonClass).Name, bodyParameterDescription.Name); + Assert.Equal("inferredJsonClass", bodyParameterDescription.Name); Assert.True(bodyParameterDescription.IsRequired); // Assert @@ -783,10 +804,6 @@ public void HandleDefaultIAcceptsMetadataForRequiredBodyParameter() Assert.Equal("application/json", defaultRequestFormat.MediaType); } -#nullable restore - -#nullable enable - [Fact] public void HandleDefaultIAcceptsMetadataForOptionalBodyParameter() { @@ -812,7 +829,7 @@ public void HandleDefaultIAcceptsMetadataForOptionalBodyParameter() var parameterDescriptions = context.Results.SelectMany(r => r.ParameterDescriptions); var bodyParameterDescription = parameterDescriptions.Single(); Assert.Equal(typeof(InferredJsonClass), bodyParameterDescription.Type); - Assert.Equal(typeof(InferredJsonClass).Name, bodyParameterDescription.Name); + Assert.Equal("inferredJsonClass", bodyParameterDescription.Name); Assert.False(bodyParameterDescription.IsRequired); // Assert @@ -821,10 +838,6 @@ public void HandleDefaultIAcceptsMetadataForOptionalBodyParameter() Assert.Equal("application/json", defaultRequestFormat.MediaType); } -#nullable restore - -#nullable enable - [Fact] public void HandleIAcceptsMetadataWithConsumesAttributeAndInferredOptionalFromBodyType() { @@ -849,9 +862,9 @@ public void HandleIAcceptsMetadataWithConsumesAttributeAndInferredOptionalFromBo // Assert var parameterDescriptions = context.Results.SelectMany(r => r.ParameterDescriptions); var bodyParameterDescription = parameterDescriptions.Single(); - Assert.Equal(typeof(void), bodyParameterDescription.Type); - Assert.Equal(typeof(void).Name, bodyParameterDescription.Name); - Assert.True(bodyParameterDescription.IsRequired); + Assert.Equal(typeof(InferredJsonClass), bodyParameterDescription.Type); + Assert.Equal("inferredJsonClass", bodyParameterDescription.Name); + Assert.False(bodyParameterDescription.IsRequired); // Assert var requestFormats = context.Results.SelectMany(r => r.SupportedRequestFormats); @@ -859,6 +872,190 @@ public void HandleIAcceptsMetadataWithConsumesAttributeAndInferredOptionalFromBo Assert.Equal("application/xml", defaultRequestFormat.MediaType); } + [Fact] + public void HandleDefaultIAcceptsMetadataForRequiredFormFileParameter() + { + // Arrange + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + var builder = new TestEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + builder.MapPost("/file/upload", (IFormFile formFile) => ""); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + // Act + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + // Assert + var parameterDescriptions = context.Results.SelectMany(r => r.ParameterDescriptions); + var bodyParameterDescription = parameterDescriptions.Single(); + Assert.Equal(typeof(IFormFile), bodyParameterDescription.Type); + Assert.Equal("formFile", bodyParameterDescription.Name); + Assert.True(bodyParameterDescription.IsRequired); + + // Assert + var requestFormats = context.Results.SelectMany(r => r.SupportedRequestFormats); + var defaultRequestFormat = requestFormats.Single(); + Assert.Equal("multipart/form-data", defaultRequestFormat.MediaType); + Assert.Null(defaultRequestFormat.Formatter); + } + + [Fact] + public void HandleDefaultIAcceptsMetadataForOptionalFormFileParameter() + { + // Arrange + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + var builder = new TestEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + builder.MapPost("/file/upload", (IFormFile? inferredFormFile) => ""); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + // Act + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + // Assert + var parameterDescriptions = context.Results.SelectMany(r => r.ParameterDescriptions); + var bodyParameterDescription = parameterDescriptions.Single(); + Assert.Equal(typeof(IFormFile), bodyParameterDescription.Type); + Assert.Equal("inferredFormFile", bodyParameterDescription.Name); + Assert.False(bodyParameterDescription.IsRequired); + + // Assert + var requestFormats = context.Results.SelectMany(r => r.SupportedRequestFormats); + var defaultRequestFormat = requestFormats.Single(); + Assert.Equal("multipart/form-data", defaultRequestFormat.MediaType); + Assert.Null(defaultRequestFormat.Formatter); + } + + [Fact] + public void AddsMultipartFormDataResponseFormatWhenFormFileSpecified() + { + // Arrange + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + var builder = new TestEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + builder.MapPost("/file/upload", (IFormFile file) => Results.NoContent()); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + // Act + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + // Assert + var parameterDescriptions = context.Results.SelectMany(r => r.ParameterDescriptions); + var bodyParameterDescription = parameterDescriptions.Single(); + Assert.Equal(typeof(IFormFile), bodyParameterDescription.Type); + Assert.Equal("file", bodyParameterDescription.Name); + Assert.True(bodyParameterDescription.IsRequired); + + // Assert + var requestFormats = context.Results.SelectMany(r => r.SupportedRequestFormats); + var defaultRequestFormat = requestFormats.Single(); + Assert.Equal("multipart/form-data", defaultRequestFormat.MediaType); + Assert.Null(defaultRequestFormat.Formatter); + } + + [Fact] + public void HasMultipleRequestFormatsWhenFormFileSpecifiedWithConsumedAttribute() + { + var apiDescription = GetApiDescription( + [Consumes("application/custom0", "application/custom1")] + (IFormFile file) => Results.NoContent()); + + Assert.Equal(2, apiDescription.SupportedRequestFormats.Count); + + var requestFormat0 = apiDescription.SupportedRequestFormats[0]; + Assert.Equal("application/custom0", requestFormat0.MediaType); + Assert.Null(requestFormat0.Formatter); + + var requestFormat1 = apiDescription.SupportedRequestFormats[1]; + Assert.Equal("application/custom1", requestFormat1.MediaType); + Assert.Null(requestFormat1.Formatter); + } + + [Fact] + public void TestIsRequiredFromFormFile() + { + var apiDescription0 = GetApiDescription((IFormFile fromFile) => { }); + var apiDescription1 = GetApiDescription((IFormFile? fromFile) => { }); + Assert.Equal(1, apiDescription0.ParameterDescriptions.Count); + Assert.Equal(1, apiDescription1.ParameterDescriptions.Count); + + var fromFileParam0 = apiDescription0.ParameterDescriptions[0]; + Assert.Equal(typeof(IFormFile), fromFileParam0.Type); + Assert.Equal(typeof(IFormFile), fromFileParam0.ModelMetadata.ModelType); + Assert.Equal(BindingSource.FormFile, fromFileParam0.Source); + Assert.True(fromFileParam0.IsRequired); + + var fromFileParam1 = apiDescription1.ParameterDescriptions[0]; + Assert.Equal(typeof(IFormFile), fromFileParam1.Type); + Assert.Equal(typeof(IFormFile), fromFileParam1.ModelMetadata.ModelType); + Assert.Equal(BindingSource.FormFile, fromFileParam1.Source); + Assert.False(fromFileParam1.IsRequired); + } + + [Fact] + public void AddsFromFormParameterAsFormFile() + { + static void AssertFormFileParameter(ApiDescription apiDescription, Type expectedType, string expectedName) + { + var param = Assert.Single(apiDescription.ParameterDescriptions); + Assert.Equal(expectedType, param.Type); + Assert.Equal(expectedType, param.ModelMetadata.ModelType); + Assert.Equal(BindingSource.FormFile, param.Source); + Assert.Equal(expectedName, param.Name); + } + + AssertFormFileParameter(GetApiDescription((IFormFile file) => { }), typeof(IFormFile), "file"); + AssertFormFileParameter(GetApiDescription(([FromForm(Name = "file_name")] IFormFile file) => { }), typeof(IFormFile), "file_name"); + } + + [Fact] + public void AddsMultipartFormDataResponseFormatWhenFormFileCollectionSpecified() + { + AssertFormFileCollection((IFormFileCollection files) => Results.NoContent(), "files"); + AssertFormFileCollection(([FromForm] IFormFileCollection uploads) => Results.NoContent(), "uploads"); + + static void AssertFormFileCollection(Delegate handler, string expectedName) + { + // Arrange + var services = new ServiceCollection(); + var serviceProvider = services.BuildServiceProvider(); + var builder = new TestEndpointRouteBuilder(new ApplicationBuilder(serviceProvider)); + builder.MapPost("/file/upload", handler); + var context = new ApiDescriptionProviderContext(Array.Empty()); + + var endpointDataSource = builder.DataSources.OfType().Single(); + var provider = CreateEndpointMetadataApiDescriptionProvider(endpointDataSource); + + // Act + provider.OnProvidersExecuting(context); + provider.OnProvidersExecuted(context); + + // Assert + var parameterDescriptions = context.Results.SelectMany(r => r.ParameterDescriptions); + var bodyParameterDescription = parameterDescriptions.Single(); + Assert.Equal(typeof(IFormFileCollection), bodyParameterDescription.Type); + Assert.Equal(expectedName, bodyParameterDescription.Name); + Assert.True(bodyParameterDescription.IsRequired); + + var requestFormats = context.Results.SelectMany(r => r.SupportedRequestFormats); + var defaultRequestFormat = requestFormats.Single(); + Assert.Equal("multipart/form-data", defaultRequestFormat.MediaType); + Assert.Null(defaultRequestFormat.Formatter); + } + } + #nullable restore [Fact] @@ -970,7 +1167,6 @@ private static IList GetApiDescriptions( private static TestEndpointRouteBuilder CreateBuilder() => new TestEndpointRouteBuilder(new ApplicationBuilder(new TestServiceProvider())); - private static ApiDescription GetApiDescription(Delegate action, string pattern = null, string displayName = null) => Assert.Single(GetApiDescriptions(action, pattern, displayName: displayName)); diff --git a/src/Mvc/Mvc.Core/src/FromFormAttribute.cs b/src/Mvc/Mvc.Core/src/FromFormAttribute.cs index aec07a14fc1a..3e1b705e35af 100644 --- a/src/Mvc/Mvc.Core/src/FromFormAttribute.cs +++ b/src/Mvc/Mvc.Core/src/FromFormAttribute.cs @@ -1,7 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -11,7 +10,7 @@ namespace Microsoft.AspNetCore.Mvc; /// Specifies that a parameter or property should be bound using form-data in the request body. /// [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);