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..4919138b4d2e --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinder.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; + +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) + { + bindingContext.ModelState.AddModelError(bindingContext.ModelName, "Invalid JSON payload."); + bindingContext.Result = ModelBindingResult.Failed(); + } + 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 new file mode 100644 index 000000000000..d25dfbb6e7e1 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/FromFormOrJsonModelBinderProvider.cs @@ -0,0 +1,32 @@ +using System; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using System.Linq; + +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 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); + 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..90bb90a6b0b8 --- /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 sealed class FromFormOrJsonAttribute : Attribute, IBindingSourceMetadata +{ + public BindingSource BindingSource => BindingSource.Custom; +} 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..50b6a930309a --- /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.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