From 6bcf11ff7d9c8eec12adea99a5371423134ce274 Mon Sep 17 00:00:00 2001 From: Bruno Hashimoto Date: Fri, 9 May 2025 14:55:50 -0400 Subject: [PATCH 1/2] Add FromFormOrJsonModelBinder to support JSON or multipart requests --- .../Binders/FromFormOrJsonModelBinder.cs | 82 +++++++++++++++++ .../FromFormOrJsonModelBinderProvider.cs | 28 ++++++ .../ModelBinding/FromFormOrJsonAttribute.cs | 12 +++ .../src/ModelBinding/IFormFileReceiver.cs | 11 +++ .../Binders/FromFormOrJsonModelBinderTest.cs | 87 +++++++++++++++++++ 5 files changed, 220 insertions(+) create mode 100644 src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinder.cs create mode 100644 src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinderProvider.cs create mode 100644 src/Mvc/Mvc.Core/src/ModelBinding/FromFormOrJsonAttribute.cs create mode 100644 src/Mvc/Mvc.Core/src/ModelBinding/IFormFileReceiver.cs create mode 100644 src/Mvc/Mvc.Core/test/ModelBinding/Binders/FromFormOrJsonModelBinderTest.cs diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinder.cs new file mode 100644 index 000000000000..69c1bf2ab3d5 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinder.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; + +/// +/// A generic ModelBinder that supports both 'application/json' and 'multipart/form-data' content types. +/// For multipart requests, it expects a 'payload' field containing a JSON object. +/// Optionally, the target model can implement to receive uploaded files from the form. +/// +public class FromFormOrJsonModelBinder : IModelBinder where T : class +{ + private static readonly JsonSerializerOptions _defaultJsonOptions = new() { PropertyNameCaseInsensitive = true }; + + public async Task BindModelAsync(ModelBindingContext bindingContext) + { + var request = bindingContext.HttpContext.Request; + var modelName = bindingContext.ModelName; + + if (string.IsNullOrEmpty(request.ContentType)) + { + bindingContext.ModelState.AddModelError(modelName, "Missing Content-Type header."); + bindingContext.Result = ModelBindingResult.Failed(); + return; + } + + try + { + T? model = null; + + if (request.ContentType.StartsWith("multipart/form-data", StringComparison.OrdinalIgnoreCase)) + { + var form = await request.ReadFormAsync(bindingContext.HttpContext.RequestAborted); + + if (form.TryGetValue("payload", out var payloadJson) && !StringValues.IsNullOrEmpty(payloadJson)) + { + model = JsonSerializer.Deserialize(payloadJson.ToString(), _defaultJsonOptions); + } + + if (model is IFormFileReceiver receiver) + { + receiver.SetFiles(form.Files); + } + } + else if (request.ContentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase)) + { + model = await JsonSerializer.DeserializeAsync( + request.Body, + _defaultJsonOptions, + bindingContext.HttpContext.RequestAborted + ); + } + else + { + bindingContext.ModelState.AddModelError(modelName, $"Unsupported Content-Type '{request.ContentType}'."); + bindingContext.Result = ModelBindingResult.Failed(); + return; + } + + if (model is null) + { + bindingContext.ModelState.AddModelError(modelName, "Could not deserialize the request body."); + bindingContext.Result = ModelBindingResult.Failed(); + return; + } + + bindingContext.Result = ModelBindingResult.Success(model); + } + catch (JsonException ex) + { + bindingContext.ModelState.AddModelError(bindingContext.ModelName, "Invalid JSON payload."); + bindingContext.Result = ModelBindingResult.Failed(); + } + catch (Exception ex) + { + bindingContext.ModelState.AddModelError(bindingContext.ModelName, "Unexpected error during model binding."); + bindingContext.Result = ModelBindingResult.Failed(); + } + } +} diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinderProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinderProvider.cs new file mode 100644 index 000000000000..355710cfc4da --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinderProvider.cs @@ -0,0 +1,28 @@ +using System; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders +{ + /// + /// Provides a model binder for parameters annotated with FromFormOrJsonAttribute. + /// + public class FromFormOrJsonModelBinderProvider : IModelBinderProvider + { + public IModelBinder? GetBinder(ModelBinderProviderContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var binderAttribute = context.Metadata.BinderMetadata as FromFormOrJsonAttribute; + if (binderAttribute != null) + { + var binderType = typeof(FromFormOrJsonModelBinder<>).MakeGenericType(context.Metadata.ModelType); + return (IModelBinder?)Activator.CreateInstance(binderType); + } + + return null; + } + } +} diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/FromFormOrJsonAttribute.cs b/src/Mvc/Mvc.Core/src/ModelBinding/FromFormOrJsonAttribute.cs new file mode 100644 index 000000000000..5dd211da0757 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/FromFormOrJsonAttribute.cs @@ -0,0 +1,12 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace Microsoft.AspNetCore.Mvc; + +/// +/// Indicates that a parameter should be bound using the FromFormOrJsonModelBinder. +/// +[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] +public class FromFormOrJsonAttribute : ModelBinderAttribute +{ + public FromFormOrJsonAttribute() : base(typeof(FromFormOrJsonModelBinder<>)) { } +} diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/IFormFileReceiver.cs b/src/Mvc/Mvc.Core/src/ModelBinding/IFormFileReceiver.cs new file mode 100644 index 000000000000..7319c5685aaa --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/IFormFileReceiver.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding; + +/// +/// Optional interface for models that wish to receive uploaded files from multipart/form-data requests. +/// +public interface IFormFileReceiver +{ + void SetFiles(IFormFileCollection files); +} diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/FromFormOrJsonModelBinderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/FromFormOrJsonModelBinderTest.cs new file mode 100644 index 000000000000..d71f222fa937 --- /dev/null +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/FromFormOrJsonModelBinderTest.cs @@ -0,0 +1,87 @@ +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Internal; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Tests; + +public class FromFormOrJsonModelBinderTest +{ + private static DefaultHttpContext CreateHttpContextWithJson(T payload) + { + var context = new DefaultHttpContext(); + context.Request.ContentType = "application/json"; + + var json = JsonSerializer.Serialize(payload); + var bytes = Encoding.UTF8.GetBytes(json); + context.Request.Body = new MemoryStream(bytes); + + return context; + } + + private static DefaultHttpContext CreateHttpContextWithForm(T payload) + { + var context = new DefaultHttpContext(); + context.Request.ContentType = "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW"; + var form = new FormCollection(new Dictionary + { + { "payload", JsonSerializer.Serialize(payload) } + }); + + context.Request.Form = form; + return context; + } + + public class SampleDto + { + public string Name { get; set; } + } + + [Fact] + public async Task BindsFromJsonPayload() + { + // Arrange + var dto = new SampleDto { Name = "Json Test" }; + var httpContext = CreateHttpContextWithJson(dto); + var bindingContext = GetBindingContext(httpContext); + + var binder = new FromFormOrJsonModelBinder(); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + var result = Assert.IsType(bindingContext.Result.Model); + Assert.Equal("Json Test", result.Name); + } + + private static DefaultModelBindingContext GetBindingContext(HttpContext httpContext) + { + var metadataProvider = new EmptyModelMetadataProvider(); + var modelMetadata = metadataProvider.GetMetadataForType(typeof(T)); + return new DefaultModelBindingContext + { + ModelMetadata = modelMetadata, + ModelName = "model", + ModelState = new ModelStateDictionary(), + ValueProvider = new SimpleValueProvider(), + ActionContext = new ActionContext + { + HttpContext = httpContext, + RouteData = new Microsoft.AspNetCore.Routing.RouteData(), + ActionDescriptor = new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor() + }, + FieldName = "model", + BindingSource = BindingSource.Custom, + HttpContext = httpContext, + }; + } +} \ No newline at end of file From 0472385295c968dc17f68792ab724386d5dd1c03 Mon Sep 17 00:00:00 2001 From: Bruno Hashimoto Date: Fri, 9 May 2025 15:47:00 -0400 Subject: [PATCH 2/2] Add FromFormOrJsonModelBinder for unified JSON and multipart request support --- .../src/ModelBinding/Binders/FromFormOrJsonModelBinder.cs | 5 +++-- .../Binders/FromFormOrJsonModelBinderProvider.cs | 6 +++++- .../Mvc.Core/src/ModelBinding/FromFormOrJsonAttribute.cs | 4 ++-- .../ModelBinding/Binders/FromFormOrJsonModelBinderTest.cs | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinder.cs index 69c1bf2ab3d5..4919138b4d2e 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinder.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinder.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; @@ -68,12 +69,12 @@ public async Task BindModelAsync(ModelBindingContext bindingContext) bindingContext.Result = ModelBindingResult.Success(model); } - catch (JsonException ex) + catch (JsonException) { bindingContext.ModelState.AddModelError(bindingContext.ModelName, "Invalid JSON payload."); bindingContext.Result = ModelBindingResult.Failed(); } - catch (Exception ex) + catch (Exception) { bindingContext.ModelState.AddModelError(bindingContext.ModelName, "Unexpected error during model binding."); bindingContext.Result = ModelBindingResult.Failed(); diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinderProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinderProvider.cs index 355710cfc4da..d25dfbb6e7e1 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinderProvider.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinderProvider.cs @@ -1,5 +1,6 @@ using System; using Microsoft.AspNetCore.Mvc.ModelBinding; +using System.Linq; namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders { @@ -15,7 +16,10 @@ public class FromFormOrJsonModelBinderProvider : IModelBinderProvider throw new ArgumentNullException(nameof(context)); } - var binderAttribute = context.Metadata.BinderMetadata as FromFormOrJsonAttribute; + var parameter = context.Metadata.ParameterInfo; + var binderAttribute = parameter?.GetCustomAttributes(typeof(FromFormOrJsonAttribute), true) + .FirstOrDefault() as FromFormOrJsonAttribute; + if (binderAttribute != null) { var binderType = typeof(FromFormOrJsonModelBinder<>).MakeGenericType(context.Metadata.ModelType); diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/FromFormOrJsonAttribute.cs b/src/Mvc/Mvc.Core/src/ModelBinding/FromFormOrJsonAttribute.cs index 5dd211da0757..90bb90a6b0b8 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/FromFormOrJsonAttribute.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/FromFormOrJsonAttribute.cs @@ -6,7 +6,7 @@ namespace Microsoft.AspNetCore.Mvc; /// Indicates that a parameter should be bound using the FromFormOrJsonModelBinder. /// [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] -public class FromFormOrJsonAttribute : ModelBinderAttribute +public sealed class FromFormOrJsonAttribute : Attribute, IBindingSourceMetadata { - public FromFormOrJsonAttribute() : base(typeof(FromFormOrJsonModelBinder<>)) { } + public BindingSource BindingSource => BindingSource.Custom; } diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/FromFormOrJsonModelBinderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/FromFormOrJsonModelBinderTest.cs index d71f222fa937..50b6a930309a 100644 --- a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/FromFormOrJsonModelBinderTest.cs +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/FromFormOrJsonModelBinderTest.cs @@ -3,11 +3,11 @@ using System.Text.Json; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Internal; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; + using Xunit; namespace Microsoft.AspNetCore.Mvc.ModelBinding.Tests;