From 457f56abf5c0527f012b92cbd10fd388c2a1f050 Mon Sep 17 00:00:00 2001 From: Raymond Masciarella Date: Sun, 26 Apr 2026 05:53:56 -0400 Subject: [PATCH 1/2] Honor [Consumes] / IAcceptsMetadata on form endpoints --- .../using_form_parameters.cs | 18 +++++++++++++++ .../HttpChain.ApiDescription.cs | 23 +++++++++++++++++++ .../WolverineWebApi/Forms/FormEndpoints.cs | 4 ++++ 3 files changed, 45 insertions(+) diff --git a/src/Http/Wolverine.Http.Tests/using_form_parameters.cs b/src/Http/Wolverine.Http.Tests/using_form_parameters.cs index b37311574..9bf40c790 100644 --- a/src/Http/Wolverine.Http.Tests/using_form_parameters.cs +++ b/src/Http/Wolverine.Http.Tests/using_form_parameters.cs @@ -536,4 +536,22 @@ public void trouble_shoot_form_matching() var variable = chain.TryFindOrCreateFormValue(parameter); variable!.Creator.ShouldBeOfType(); } + + [Fact] + public void form_endpoints_honor_consumes_metadata_for_supported_request_formats() + { + // fillRequestType only seeds SupportedRequestFormats from + // IAcceptsMetadata when the endpoint has a body request type and is + // not a form endpoint. Form endpoints used to fall through and rely + // on ASP.NET Core OpenAPI's "application/x-www-form-urlencoded" + // default, which silently dropped [Consumes("multipart/form-data")] + // and caused generated clients (Orval, NSwag) to emit URLSearchParams + // bodies instead of multipart for file-upload endpoints. + var chain = HttpChains.ChainFor("POST", "/form/multipart-consumes"); + var apiDescription = chain!.CreateApiDescription("POST"); + + apiDescription.SupportedRequestFormats + .Select(f => f.MediaType) + .ShouldContain("multipart/form-data"); + } } \ No newline at end of file diff --git a/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs b/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs index 93442505d..18895df65 100644 --- a/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs +++ b/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs @@ -158,6 +158,29 @@ public ApiDescription CreateApiDescription(string httpMethod) apiDescription.ParameterDescriptions.Add(parameterDescription); } + // fillRequestType only honors [Consumes] / IAcceptsMetadata for body + // endpoints (HasRequestType && !IsFormData). Without this, form + // endpoints fall through and ASP.NET Core OpenAPI's GetFormRequestBody + // defaults SupportedRequestFormats to "application/x-www-form-urlencoded" + // — silently dropping [Consumes("multipart/form-data")] on file-upload + // endpoints and causing client generators (Orval, NSwag, Kiota) to emit + // URLSearchParams bodies instead of multipart. + if (apiDescription.SupportedRequestFormats.Count == 0 && + apiDescription.ParameterDescriptions.Any(p => + p.Source == BindingSource.Form || p.Source == BindingSource.FormFile)) + { + foreach (var metadata in Endpoint!.Metadata.OfType()) + { + foreach (var contentType in metadata.ContentTypes) + { + apiDescription.SupportedRequestFormats.Add(new ApiRequestFormat + { + MediaType = contentType + }); + } + } + } + return apiDescription; } diff --git a/src/Http/WolverineWebApi/Forms/FormEndpoints.cs b/src/Http/WolverineWebApi/Forms/FormEndpoints.cs index 00b7b34e8..5046b44d6 100644 --- a/src/Http/WolverineWebApi/Forms/FormEndpoints.cs +++ b/src/Http/WolverineWebApi/Forms/FormEndpoints.cs @@ -111,6 +111,10 @@ public static string PostWithFiles([FromForm] FormWithFiles form) { return $"{form.Name}|{form.Files?.Count}"; } + + [WolverinePost("/form/multipart-consumes")] + [Consumes("multipart/form-data")] + public static string MultipartConsumes([FromForm] string value) => value ?? ""; } public class FormWithFile From 6db8478423742f2ad86285b550a7fc26c477f910 Mon Sep 17 00:00:00 2001 From: Raymond Masciarella Date: Sun, 26 Apr 2026 06:02:09 -0400 Subject: [PATCH 2/2] Address review: extract IAcceptsMetadata helper, fix comment accuracy --- .../using_form_parameters.cs | 13 ++--- .../HttpChain.ApiDescription.cs | 48 +++++++++---------- 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/src/Http/Wolverine.Http.Tests/using_form_parameters.cs b/src/Http/Wolverine.Http.Tests/using_form_parameters.cs index 9bf40c790..e9dddb437 100644 --- a/src/Http/Wolverine.Http.Tests/using_form_parameters.cs +++ b/src/Http/Wolverine.Http.Tests/using_form_parameters.cs @@ -541,12 +541,13 @@ public void trouble_shoot_form_matching() public void form_endpoints_honor_consumes_metadata_for_supported_request_formats() { // fillRequestType only seeds SupportedRequestFormats from - // IAcceptsMetadata when the endpoint has a body request type and is - // not a form endpoint. Form endpoints used to fall through and rely - // on ASP.NET Core OpenAPI's "application/x-www-form-urlencoded" - // default, which silently dropped [Consumes("multipart/form-data")] - // and caused generated clients (Orval, NSwag) to emit URLSearchParams - // bodies instead of multipart for file-upload endpoints. + // IAcceptsMetadata when the endpoint has a body request type, is + // not a form endpoint, and is not a GET. Form endpoints used to + // fall through and rely on ASP.NET Core OpenAPI's + // "application/x-www-form-urlencoded" default, which silently + // dropped [Consumes("multipart/form-data")] and caused generated + // clients (Orval, NSwag) to emit URLSearchParams bodies instead of + // multipart for file-upload endpoints. var chain = HttpChains.ChainFor("POST", "/form/multipart-consumes"); var apiDescription = chain!.CreateApiDescription("POST"); diff --git a/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs b/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs index 18895df65..55f7d0b84 100644 --- a/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs +++ b/src/Http/Wolverine.Http/HttpChain.ApiDescription.cs @@ -158,30 +158,37 @@ public ApiDescription CreateApiDescription(string httpMethod) apiDescription.ParameterDescriptions.Add(parameterDescription); } - // fillRequestType only honors [Consumes] / IAcceptsMetadata for body - // endpoints (HasRequestType && !IsFormData). Without this, form - // endpoints fall through and ASP.NET Core OpenAPI's GetFormRequestBody - // defaults SupportedRequestFormats to "application/x-www-form-urlencoded" - // — silently dropping [Consumes("multipart/form-data")] on file-upload - // endpoints and causing client generators (Orval, NSwag, Kiota) to emit + // fillRequestType only honors [Consumes] / IAcceptsMetadata when the + // endpoint has a body request type, is not a form endpoint, and is + // not a GET (HasRequestType && !IsFormData && HttpMethod != "GET"). + // Without this, form endpoints fall through and ASP.NET Core OpenAPI's + // GetFormRequestBody defaults SupportedRequestFormats to + // "application/x-www-form-urlencoded" — silently dropping + // [Consumes("multipart/form-data")] on file-upload endpoints and + // causing client generators (Orval, NSwag, Kiota) to emit // URLSearchParams bodies instead of multipart. if (apiDescription.SupportedRequestFormats.Count == 0 && apiDescription.ParameterDescriptions.Any(p => p.Source == BindingSource.Form || p.Source == BindingSource.FormFile)) { - foreach (var metadata in Endpoint!.Metadata.OfType()) + copyAcceptsMetadataToRequestFormats(apiDescription); + } + + return apiDescription; + } + + private void copyAcceptsMetadataToRequestFormats(ApiDescription apiDescription) + { + foreach (var metadata in Endpoint!.Metadata.OfType()) + { + foreach (var contentType in metadata.ContentTypes) { - foreach (var contentType in metadata.ContentTypes) + apiDescription.SupportedRequestFormats.Add(new ApiRequestFormat { - apiDescription.SupportedRequestFormats.Add(new ApiRequestFormat - { - MediaType = contentType - }); - } + MediaType = contentType + }); } } - - return apiDescription; } public override MiddlewareScoping Scoping => MiddlewareScoping.HttpEndpoints; @@ -356,16 +363,7 @@ private void fillRequestType(ApiDescription apiDescription) apiDescription.ParameterDescriptions.Add(parameterDescription); - foreach (var metadata in Endpoint!.Metadata.OfType()) - { - foreach (var contentType in metadata.ContentTypes) - { - apiDescription.SupportedRequestFormats.Add(new ApiRequestFormat - { - MediaType = contentType - }); - } - } + copyAcceptsMetadataToRequestFormats(apiDescription); } }