diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs index b44c17c6e57f..cc9f5dadfb0c 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactory.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactory.cs @@ -61,6 +61,7 @@ public static partial class RequestDelegateFactory private static readonly PropertyInfo RouteValuesIndexerProperty = typeof(RouteValueDictionary).GetProperty("Item")!; private static readonly PropertyInfo HeaderIndexerProperty = typeof(IHeaderDictionary).GetProperty("Item")!; private static readonly PropertyInfo FormFilesIndexerProperty = typeof(IFormFileCollection).GetProperty("Item")!; + private static readonly PropertyInfo FormIndexerProperty = typeof(IFormCollection).GetProperty("Item")!; private static readonly MethodInfo JsonResultWriteResponseAsyncMethod = typeof(RequestDelegateFactory).GetMethod(nameof(WriteJsonResponse), BindingFlags.NonPublic | BindingFlags.Static)!; @@ -110,6 +111,7 @@ public static partial class RequestDelegateFactory private static readonly string[] DefaultAcceptsAndProducesContentType = new[] { JsonConstants.JsonContentType }; private static readonly string[] FormFileContentType = new[] { "multipart/form-data" }; + private static readonly string[] FormContentType = new[] { "multipart/form-data", "application/x-www-form-urlencoded" }; private static readonly string[] PlaintextContentType = new[] { "text/plain" }; /// @@ -377,6 +379,12 @@ private static Expression[] CreateArgumentsAndInferMetadata(MethodInfo methodInf if (!factoryContext.MetadataAlreadyInferred) { + if (factoryContext.ReadForm) + { + // Add the Accepts metadata when reading from FORM. + InferFormAcceptsMetadata(factoryContext); + } + PopulateBuiltInResponseTypeMetadata(methodInfo.ReturnType, factoryContext.EndpointBuilder); // Add metadata provided by the delegate return type and parameter types next, this will be more specific than inferred metadata from above @@ -710,13 +718,22 @@ private static Expression CreateArgument(ParameterInfo parameter, RequestDelegat return BindParameterFromFormFiles(parameter, factoryContext); } - else if (parameter.ParameterType != typeof(IFormFile)) + 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.ParameterType == typeof(IFormCollection)) + { + 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(IFormCollection)}."); + + } + return BindParameterFromFormCollection(parameter, factoryContext); } - return BindParameterFromFormFile(parameter, formAttribute.Name ?? parameter.Name, factoryContext, RequestDelegateFactoryConstants.FormFileAttribute); + return BindParameterFromFormItem(parameter, formAttribute.Name ?? parameter.Name, factoryContext); } else if (parameter.CustomAttributes.Any(a => typeof(IFromServiceMetadata).IsAssignableFrom(a.AttributeType))) { @@ -753,6 +770,10 @@ private static Expression CreateArgument(ParameterInfo parameter, RequestDelegat { return RequestAbortedExpr; } + else if (parameter.ParameterType == typeof(IFormCollection)) + { + return BindParameterFromFormCollection(parameter, factoryContext); + } else if (parameter.ParameterType == typeof(IFormFileCollection)) { return BindParameterFromFormFiles(parameter, factoryContext); @@ -1820,52 +1841,85 @@ private static void AddInferredAcceptsMetadata(RequestDelegateFactoryContext fac factoryContext.EndpointBuilder.Metadata.Add(new AcceptsMetadata(type, factoryContext.AllowEmptyRequestBody, contentTypes)); } - private static Expression BindParameterFromFormFiles( - ParameterInfo parameter, - RequestDelegateFactoryContext factoryContext) + private static void InferFormAcceptsMetadata(RequestDelegateFactoryContext factoryContext) { - if (factoryContext.FirstFormRequestBodyParameter is null) + if (factoryContext.ReadFormFile) { - factoryContext.FirstFormRequestBodyParameter = parameter; + AddInferredAcceptsMetadata(factoryContext, factoryContext.FirstFormRequestBodyParameter!.ParameterType, FormFileContentType); } - - factoryContext.TrackedParameters.Add(parameter.Name!, RequestDelegateFactoryConstants.FormFileParameter); - - // Do not duplicate the metadata if there are multiple form parameters - if (!factoryContext.ReadForm) + else { - AddInferredAcceptsMetadata(factoryContext, parameter.ParameterType, FormFileContentType); + AddInferredAcceptsMetadata(factoryContext, factoryContext.FirstFormRequestBodyParameter!.ParameterType, FormContentType); } + } + private static Expression BindParameterFromFormCollection( + ParameterInfo parameter, + RequestDelegateFactoryContext factoryContext) + { + factoryContext.FirstFormRequestBodyParameter ??= parameter; + factoryContext.TrackedParameters.Add(parameter.Name!, RequestDelegateFactoryConstants.FormCollectionParameter); factoryContext.ReadForm = true; - return BindParameterFromExpression(parameter, FormFilesExpr, factoryContext, "body"); + return BindParameterFromExpression( + parameter, + FormExpr, + factoryContext, + "body"); } - private static Expression BindParameterFromFormFile( + private static Expression BindParameterFromFormItem( ParameterInfo parameter, string key, - RequestDelegateFactoryContext factoryContext, - string trackedParameterSource) + RequestDelegateFactoryContext factoryContext) { - if (factoryContext.FirstFormRequestBodyParameter is null) - { - factoryContext.FirstFormRequestBodyParameter = parameter; - } + var valueExpression = GetValueFromProperty(FormExpr, FormIndexerProperty, key, GetExpressionType(parameter.ParameterType)); - factoryContext.TrackedParameters.Add(key, trackedParameterSource); + factoryContext.FirstFormRequestBodyParameter ??= parameter; + factoryContext.TrackedParameters.Add(key, RequestDelegateFactoryConstants.FormAttribute); + factoryContext.ReadForm = true; - // Do not duplicate the metadata if there are multiple form parameters - if (!factoryContext.ReadForm) - { - AddInferredAcceptsMetadata(factoryContext, parameter.ParameterType, FormFileContentType); - } + return BindParameterFromValue( + parameter, + valueExpression, + factoryContext, + "form"); + } + private static Expression BindParameterFromFormFiles( + ParameterInfo parameter, + RequestDelegateFactoryContext factoryContext) + { + factoryContext.FirstFormRequestBodyParameter ??= parameter; + factoryContext.TrackedParameters.Add(parameter.Name!, RequestDelegateFactoryConstants.FormFileParameter); factoryContext.ReadForm = true; + factoryContext.ReadFormFile = true; + + return BindParameterFromExpression( + parameter, + FormFilesExpr, + factoryContext, + "body"); + } + private static Expression BindParameterFromFormFile( + ParameterInfo parameter, + string key, + RequestDelegateFactoryContext factoryContext, + string trackedParameterSource) + { var valueExpression = GetValueFromProperty(FormFilesExpr, FormFilesIndexerProperty, key, typeof(IFormFile)); - return BindParameterFromExpression(parameter, valueExpression, factoryContext, "form file"); + factoryContext.FirstFormRequestBodyParameter ??= parameter; + factoryContext.TrackedParameters.Add(key, trackedParameterSource); + factoryContext.ReadForm = true; + factoryContext.ReadFormFile = true; + + return BindParameterFromExpression( + parameter, + valueExpression, + factoryContext, + "form file"); } private static Expression BindParameterFromBody(ParameterInfo parameter, bool allowEmpty, RequestDelegateFactoryContext factoryContext) @@ -2210,12 +2264,14 @@ private static class RequestDelegateFactoryConstants public const string BodyAttribute = "Body (Attribute)"; public const string ServiceAttribute = "Service (Attribute)"; public const string FormFileAttribute = "Form File (Attribute)"; + public const string FormAttribute = "Form (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)"; + public const string FormCollectionParameter = "Form Collection (Inferred)"; public const string PropertyAsParameter = "As Parameter (Attribute)"; } diff --git a/src/Http/Http.Extensions/src/RequestDelegateFactoryContext.cs b/src/Http/Http.Extensions/src/RequestDelegateFactoryContext.cs index 65b9d7b0f964..1f0c0963ae19 100644 --- a/src/Http/Http.Extensions/src/RequestDelegateFactoryContext.cs +++ b/src/Http/Http.Extensions/src/RequestDelegateFactoryContext.cs @@ -45,6 +45,7 @@ internal sealed class RequestDelegateFactoryContext public NullabilityInfoContext NullabilityContext { get; } = new(); public bool ReadForm { get; set; } + public bool ReadFormFile { get; set; } public ParameterInfo? FirstFormRequestBodyParameter { get; set; } // Properties for constructing and managing filters public List ContextArgAccess { get; } = new(); diff --git a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs index 199b03fb12e5..d4a3ee1e4231 100644 --- a/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs +++ b/src/Http/Http.Extensions/test/RequestDelegateFactoryTests.cs @@ -920,12 +920,19 @@ public async Task RequestDelegateHandlesArraysFromExplicitQueryStringSource() httpContext.Request.Headers["Custom"] = new(new[] { "4", "5", "6" }); + httpContext.Request.Form = new FormCollection(new Dictionary + { + ["form"] = new(new[] { "7", "8", "9" }) + }); + var factoryResult = RequestDelegateFactory.Create((HttpContext context, [FromHeader(Name = "Custom")] int[] headerValues, - [FromQuery(Name = "a")] int[] queryValues) => + [FromQuery(Name = "a")] int[] queryValues, + [FromForm(Name = "form")] int[] formValues) => { context.Items["headers"] = headerValues; context.Items["query"] = queryValues; + context.Items["form"] = formValues; }); var requestDelegate = factoryResult.RequestDelegate; @@ -934,6 +941,7 @@ public async Task RequestDelegateHandlesArraysFromExplicitQueryStringSource() Assert.Equal(new[] { 1, 2, 3 }, (int[])httpContext.Items["query"]!); Assert.Equal(new[] { 4, 5, 6 }, (int[])httpContext.Items["headers"]!); + Assert.Equal(new[] { 7, 8, 9 }, (int[])httpContext.Items["form"]!); } [Fact] @@ -947,12 +955,19 @@ public async Task RequestDelegateHandlesStringValuesFromExplicitQueryStringSourc httpContext.Request.Headers["Custom"] = new(new[] { "4", "5", "6" }); + httpContext.Request.Form = new FormCollection(new Dictionary + { + ["form"] = new(new[] { "7", "8", "9" }) + }); + var factoryResult = RequestDelegateFactory.Create((HttpContext context, [FromHeader(Name = "Custom")] StringValues headerValues, - [FromQuery(Name = "a")] StringValues queryValues) => + [FromQuery(Name = "a")] StringValues queryValues, + [FromForm(Name = "form")] StringValues formValues) => { context.Items["headers"] = headerValues; context.Items["query"] = queryValues; + context.Items["form"] = formValues; }); var requestDelegate = factoryResult.RequestDelegate; @@ -961,6 +976,7 @@ public async Task RequestDelegateHandlesStringValuesFromExplicitQueryStringSourc Assert.Equal(new StringValues(new[] { "1", "2", "3" }), httpContext.Items["query"]); Assert.Equal(new StringValues(new[] { "4", "5", "6" }), httpContext.Items["headers"]); + Assert.Equal(new StringValues(new[] { "7", "8", "9" }), httpContext.Items["form"]!); } [Fact] @@ -974,12 +990,19 @@ public async Task RequestDelegateHandlesNullableStringValuesFromExplicitQueryStr httpContext.Request.Headers["Custom"] = new(new[] { "4", "5", "6" }); + httpContext.Request.Form = new FormCollection(new Dictionary + { + ["form"] = new(new[] { "7", "8", "9" }) + }); + var factoryResult = RequestDelegateFactory.Create((HttpContext context, [FromHeader(Name = "Custom")] StringValues? headerValues, - [FromQuery(Name = "a")] StringValues? queryValues) => + [FromQuery(Name = "a")] StringValues? queryValues, + [FromForm(Name = "form")] StringValues? formValues) => { context.Items["headers"] = headerValues; context.Items["query"] = queryValues; + context.Items["form"] = formValues; }); var requestDelegate = factoryResult.RequestDelegate; @@ -988,6 +1011,7 @@ public async Task RequestDelegateHandlesNullableStringValuesFromExplicitQueryStr Assert.Equal(new StringValues(new[] { "1", "2", "3" }), httpContext.Items["query"]); Assert.Equal(new StringValues(new[] { "4", "5", "6" }), httpContext.Items["headers"]); + Assert.Equal(new StringValues(new[] { "7", "8", "9" }), httpContext.Items["form"]!); } [Fact] @@ -996,10 +1020,12 @@ public async Task RequestDelegateLogsStringValuesFromExplicitQueryStringSourceFo var invoked = false; var httpContext = CreateHttpContext(); + httpContext.Request.Form = new FormCollection(null); var factoryResult = RequestDelegateFactory.Create((HttpContext context, [FromHeader(Name = "foo")] StringValues headerValues, - [FromQuery(Name = "bar")] StringValues queryValues) => + [FromQuery(Name = "bar")] StringValues queryValues, + [FromForm(Name = "form")] StringValues formValues) => { invoked = true; }); @@ -1015,7 +1041,7 @@ public async Task RequestDelegateLogsStringValuesFromExplicitQueryStringSourceFo var logs = TestSink.Writes.ToArray(); - Assert.Equal(2, logs.Length); + Assert.Equal(3, logs.Length); Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[0].EventId); Assert.Equal(LogLevel.Debug, logs[0].LogLevel); @@ -1024,21 +1050,29 @@ public async Task RequestDelegateLogsStringValuesFromExplicitQueryStringSourceFo Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[1].EventId); Assert.Equal(LogLevel.Debug, logs[1].LogLevel); Assert.Equal(@"Required parameter ""StringValues queryValues"" was not provided from query string.", logs[1].Message); + + Assert.Equal(new EventId(4, "RequiredParameterNotProvided"), logs[2].EventId); + Assert.Equal(LogLevel.Debug, logs[2].LogLevel); + Assert.Equal(@"Required parameter ""StringValues formValues"" was not provided from form.", logs[2].Message); } [Fact] public async Task RequestDelegateHandlesNullableStringValuesFromExplicitQueryStringSourceForUnpresentedValues() { var httpContext = CreateHttpContext(); + httpContext.Request.Form = new FormCollection(null); var factoryResult = RequestDelegateFactory.Create((HttpContext context, [FromHeader(Name = "foo")] StringValues? headerValues, - [FromQuery(Name = "bar")] StringValues? queryValues) => + [FromQuery(Name = "bar")] StringValues? queryValues, + [FromForm(Name = "form")] StringValues? formValues) => { Assert.False(headerValues.HasValue); Assert.False(queryValues.HasValue); + Assert.False(formValues.HasValue); context.Items["headers"] = headerValues; context.Items["query"] = queryValues; + context.Items["form"] = formValues; }); var requestDelegate = factoryResult.RequestDelegate; @@ -1047,6 +1081,7 @@ public async Task RequestDelegateHandlesNullableStringValuesFromExplicitQueryStr Assert.Null(httpContext.Items["query"]); Assert.Null(httpContext.Items["headers"]); + Assert.Null(httpContext.Items["form"]); } [Fact] @@ -4300,25 +4335,33 @@ void TestAction(IFormFile file) } [Fact] - public void BuildRequestDelegateThrowsInvalidOperationExceptionBodyAndFormFileParameters() + public void BuildRequestDelegateThrowsInvalidOperationExceptionBodyAndFormParameters() { 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 TestFormCollectionAndJson(IFormCollection value1, Todo value2) { } + void TestFormWithAttributeAndJson([FromForm] string value1, Todo 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) { } + void TestJsonAndFormCollection(Todo value1, IFormCollection value2) { } + void TestJsonAndFormWithAttribute(Todo value1, [FromForm] string 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(TestFormCollectionAndJson)); + Assert.Throws(() => RequestDelegateFactory.Create(TestFormWithAttributeAndJson)); Assert.Throws(() => RequestDelegateFactory.Create(TestJsonAndFormFile)); Assert.Throws(() => RequestDelegateFactory.Create(TestJsonAndFormFiles)); Assert.Throws(() => RequestDelegateFactory.Create(TestJsonAndFormFileCollection)); Assert.Throws(() => RequestDelegateFactory.Create(TestJsonAndFormFileWithAttribute)); + Assert.Throws(() => RequestDelegateFactory.Create(TestJsonAndFormCollection)); + Assert.Throws(() => RequestDelegateFactory.Create(TestJsonAndFormWithAttribute)); } [Fact] @@ -4413,34 +4456,6 @@ void TestAction([FromForm(Name = "foo")] IFormFileCollection formFiles) 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() { @@ -4891,6 +4906,416 @@ void TestAction(IFormFile? file, TraceIdentifier traceId) Assert.Equal("my-trace-id", traceIdArgument.Id); } + public static TheoryData FormContent + { + get + { + var dataset = new TheoryData(); + + var multipartFormData = new MultipartFormDataContent("some-boundary"); + multipartFormData.Add(new StringContent("hello"), "message"); + multipartFormData.Add(new StringContent("foo"), "name"); + dataset.Add(multipartFormData, "multipart/form-data;boundary=some-boundary"); + + var urlEncondedForm = new FormUrlEncodedContent(new Dictionary { ["message"] = "hello", ["name"] = "foo" }); + dataset.Add(urlEncondedForm, "application/x-www-form-urlencoded"); + + return dataset; + } + } + + [Fact] + public void CreateThrowsNotSupportedExceptionIfIFormCollectionHasMetadataParameterName() + { + IFormCollection? formArgument = null; + + void TestAction([FromForm(Name = "foo")] IFormCollection formCollection) + { + formArgument = formCollection; + } + + var nse = Assert.Throws(() => RequestDelegateFactory.Create(TestAction)); + Assert.Equal("Assigning a value to the IFromFormMetadata.Name property is not supported for parameters of type IFormCollection.", nse.Message); + } + + [Theory] + [MemberData(nameof(FormContent))] + public async Task RequestDelegatePopulatesFromIFormCollectionParameter(HttpContent content, string contentType) + { + IFormCollection? formArgument = null; + + void TestAction(IFormCollection formCollection) + { + formArgument = formCollection; + } + + var stream = new MemoryStream(); + await content.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = contentType; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Request.Form, formArgument); + Assert.NotNull(formArgument); + Assert.Collection(formArgument!, + (item) => + { + Assert.Equal("message", item.Key); + Assert.Equal("hello", item.Value); + }, + (item) => + { + Assert.Equal("name", item.Key); + Assert.Equal("foo", item.Value); + }); + + var allAcceptsMetadata = factoryResult.EndpointMetadata.OfType(); + var acceptsMetadata = Assert.Single(allAcceptsMetadata); + + Assert.NotNull(acceptsMetadata); + Assert.Equal(new[] { "multipart/form-data", "application/x-www-form-urlencoded" }, acceptsMetadata.ContentTypes); + } + + [Theory] + [MemberData(nameof(FormContent))] + public async Task RequestDelegatePopulatesFromIFormCollectionParameterWithAttribute(HttpContent content, string contentType) + { + IFormCollection? formArgument = null; + + void TestAction([FromForm] IFormCollection formCollection) + { + formArgument = formCollection; + } + + var stream = new MemoryStream(); + await content.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = contentType; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Request.Form, formArgument); + Assert.NotNull(formArgument); + Assert.Collection(formArgument!, + (item) => + { + Assert.Equal("message", item.Key); + Assert.Equal("hello", item.Value); + }, + (item) => + { + Assert.Equal("name", item.Key); + Assert.Equal("foo", item.Value); + }); + + var allAcceptsMetadata = factoryResult.EndpointMetadata.OfType(); + var acceptsMetadata = Assert.Single(allAcceptsMetadata); + + Assert.NotNull(acceptsMetadata); + Assert.Equal(new[] { "multipart/form-data", "application/x-www-form-urlencoded" }, acceptsMetadata.ContentTypes); + } + + [Theory] + [MemberData(nameof(FormContent))] + public async Task RequestDelegatePopulatesFromOptionalFormParameter(HttpContent content, string contentType) + { + string? messageArgument = null; + + void TestAction([FromForm] string? message) + { + messageArgument = message; + } + + var stream = new MemoryStream(); + await content.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = contentType; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Request.Form["message"], messageArgument); + } + + [Theory] + [MemberData(nameof(FormContent))] + public async Task RequestDelegatePopulatesFromMultipleRequiredFormParameters(HttpContent content, string contentType) + { + string? messageArgument = null; + string? nameArgument = null; + + void TestAction([FromForm] string message, [FromForm] string name) + { + messageArgument = message; + nameArgument = name; + } + + var stream = new MemoryStream(); + await content.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = contentType; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Request.Form["message"], messageArgument); + Assert.NotNull(messageArgument); + + Assert.Equal(httpContext.Request.Form["name"], nameArgument); + Assert.NotNull(nameArgument); + } + + [Theory] + [MemberData(nameof(FormContent))] + public async Task RequestDelegatePopulatesFromOptionalMissingFormParameter(HttpContent content, string contentType) + { + string? messageArgument = null; + string? additionalMessageArgument = null; + + void TestAction([FromForm] string? message, [FromForm] string? additionalMessage) + { + messageArgument = message; + additionalMessageArgument = additionalMessage; + } + + var stream = new MemoryStream(); + await content.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = contentType; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Request.Form["message"], messageArgument); + Assert.NotNull(messageArgument); + Assert.Null(additionalMessageArgument); + } + + [Theory] + [MemberData(nameof(FormContent))] + public async Task RequestDelegatePopulatesFromFormParameterWithMetadata(HttpContent content, string contentType) + { + string? textArgument = null; + + void TestAction([FromForm(Name = "message")] string text) + { + textArgument = text; + } + + var stream = new MemoryStream(); + await content.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = contentType; + httpContext.Features.Set(new RequestBodyDetectionFeature(true)); + + var factoryResult = RequestDelegateFactory.Create(TestAction); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + Assert.Equal(httpContext.Request.Form["message"], textArgument); + Assert.NotNull(textArgument); + } + + [Theory] + [MemberData(nameof(FormContent))] + public async Task RequestDelegatePopulatesFromFormAndBoundParameter(HttpContent content, string contentType) + { + string? messageArgument = null; + TraceIdentifier traceIdArgument = default; + + void TestAction([FromForm] string? message, TraceIdentifier traceId) + { + messageArgument = message; + traceIdArgument = traceId; + } + + var stream = new MemoryStream(); + await content.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = contentType; + 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["message"], messageArgument); + Assert.NotNull(messageArgument); + + Assert.Equal("my-trace-id", traceIdArgument.Id); + } + + public static IEnumerable FormAndFormFileParametersDelegates + { + get + { + void TestAction(HttpContext context, IFormCollection form, IFormFileCollection formFiles) + { + context.Items["FormFilesArgument"] = formFiles; + context.Items["FormArgument"] = form; + } + + void TestActionDifferentOrder(HttpContext context, IFormFileCollection formFiles, IFormCollection form) + { + context.Items["FormFilesArgument"] = formFiles; + context.Items["FormArgument"] = form; + } + + return new List + { + new object[] { (Action)TestAction }, + new object[] { (Action)TestActionDifferentOrder }, + }; + } + } + + [Theory] + [MemberData(nameof(FormAndFormFileParametersDelegates))] + public async Task RequestDelegatePopulatesFromBothIFormCollectionAndIFormFileParameters(Delegate action) + { + var fileContent = new StringContent("hello", Encoding.UTF8, "application/octet-stream"); + var form = new MultipartFormDataContent("some-boundary"); + form.Add(fileContent, "file", "file.txt"); + form.Add(new StringContent("foo"), "name"); + + 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(action); + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + IFormFileCollection? formFilesArgument = httpContext.Items["FormFilesArgument"] as IFormFileCollection; + IFormCollection? formArgument = httpContext.Items["FormArgument"] as IFormCollection; + + Assert.Equal(httpContext.Request.Form.Files, formFilesArgument); + Assert.NotNull(formFilesArgument!["file"]); + Assert.Equal("file.txt", formFilesArgument!["file"]!.FileName); + + Assert.Equal(httpContext.Request.Form, formArgument); + Assert.NotNull(formArgument); + Assert.Collection(formArgument!, + (item) => + { + Assert.Equal("name", item.Key); + Assert.Equal("foo", item.Value); + }); + + var allAcceptsMetadata = factoryResult.EndpointMetadata.OfType(); + Assert.Collection(allAcceptsMetadata, + (m) => Assert.Equal(new[] { "multipart/form-data" }, m.ContentTypes)); + } + + [Theory] + [MemberData(nameof(FormContent))] + public async Task RequestDelegateSets400ResponseIfRequiredFormItemNotSpecified(HttpContent content, string contentType) + { + var invoked = false; + + void TestAction([FromForm] string unknownParameter) + { + invoked = true; + } + + var stream = new MemoryStream(); + await content.CopyToAsync(stream); + + stream.Seek(0, SeekOrigin.Begin); + + var httpContext = CreateHttpContext(); + httpContext.Request.Body = stream; + httpContext.Request.Headers["Content-Type"] = contentType; + 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 RequestDelegatePopulatesTryParsableParametersFromForm() + { + var httpContext = CreateHttpContext(); + + httpContext.Request.Form = new FormCollection(new Dictionary + { + ["tryParsable"] = "https://example.org" + }); + + var factoryResult = RequestDelegateFactory.Create((HttpContext httpContext, [FromForm] MyTryParseRecord tryParsable) => + { + httpContext.Items["tryParsable"] = tryParsable; + }); + + var requestDelegate = factoryResult.RequestDelegate; + + await requestDelegate(httpContext); + + var content = Assert.IsType(httpContext.Items["tryParsable"]); + Assert.Equal(new Uri("https://example.org"), content.Uri); + } + private record struct ParameterListRecordStruct(HttpContext HttpContext, [FromRoute] int Value); private record ParameterListRecordClass(HttpContext HttpContext, [FromRoute] int Value); @@ -4946,7 +5371,7 @@ public ParameterListStructWithMultipleParameterizedContructor(HttpContext httpCo Value = 10; } - public ParameterListStructWithMultipleParameterizedContructor(HttpContext httpContext, [FromHeader(Name ="Value")] int value) + public ParameterListStructWithMultipleParameterizedContructor(HttpContext httpContext, [FromHeader(Name = "Value")] int value) { HttpContext = httpContext; Value = value; @@ -5204,7 +5629,7 @@ void TestAction([AsParameters] ParameterListRecordWitDefaultValue args) private class ParameterListWitDefaultValue { - public ParameterListWitDefaultValue(HttpContext httpContext, [FromRoute]int value = 42) + public ParameterListWitDefaultValue(HttpContext httpContext, [FromRoute] int value = 42) { HttpContext = httpContext; Value = value; @@ -5945,13 +6370,13 @@ public static object[][] TasksOfTypesMethods { ValueTask ValueTaskOfStructMethod() { - return ValueTask.FromResult(new TodoStruct { Name = "Test todo"}); + return ValueTask.FromResult(new TodoStruct { Name = "Test todo" }); } async ValueTask ValueTaskOfStructWithYieldMethod() { await Task.Yield(); - return new TodoStruct { Name = "Test todo" }; + return new TodoStruct { Name = "Test todo" }; } Task TaskOfStructMethod() @@ -6461,6 +6886,27 @@ public void InferMetadata_ThenCreate_CombinesAllMetadata_InCorrectOrder() m => Assert.True(m is CustomEndpointMetadata { Source: MetadataSource.Caller })); } + [Fact] + public void InferMetadata_PopulatesAcceptsMetadata_WhenReadFromForm() + { + // Arrange + var @delegate = void (IFormCollection formCollection) => { }; + var options = new RequestDelegateFactoryOptions + { + EndpointBuilder = CreateEndpointBuilder(), + }; + + // Act + var metadataResult = RequestDelegateFactory.InferMetadata(@delegate.Method, options); + + // Assert + var allAcceptsMetadata = metadataResult.EndpointMetadata.OfType(); + var acceptsMetadata = Assert.Single(allAcceptsMetadata); + + Assert.NotNull(acceptsMetadata); + Assert.Equal(new[] { "multipart/form-data", "application/x-www-form-urlencoded" }, acceptsMetadata.ContentTypes); + } + [Fact] public void Create_AllowsRemovalOfDefaultMetadata_ByReturnTypesImplementingIEndpointMetadataProvider() {