Skip to content

Commit 6bcf11f

Browse files
committed
Add FromFormOrJsonModelBinder to support JSON or multipart requests
1 parent bc40d8b commit 6bcf11f

File tree

5 files changed

+220
-0
lines changed

5 files changed

+220
-0
lines changed
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
using System.Text.Json;
2+
using Microsoft.AspNetCore.Http;
3+
using Microsoft.AspNetCore.Mvc.ModelBinding;
4+
using Microsoft.Extensions.Logging;
5+
6+
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
7+
8+
/// <summary>
9+
/// A generic ModelBinder that supports both 'application/json' and 'multipart/form-data' content types.
10+
/// For multipart requests, it expects a 'payload' field containing a JSON object.
11+
/// Optionally, the target model can implement <see cref="IFormFileReceiver"/> to receive uploaded files from the form.
12+
/// </summary>
13+
public class FromFormOrJsonModelBinder<T> : IModelBinder where T : class
14+
{
15+
private static readonly JsonSerializerOptions _defaultJsonOptions = new() { PropertyNameCaseInsensitive = true };
16+
17+
public async Task BindModelAsync(ModelBindingContext bindingContext)
18+
{
19+
var request = bindingContext.HttpContext.Request;
20+
var modelName = bindingContext.ModelName;
21+
22+
if (string.IsNullOrEmpty(request.ContentType))
23+
{
24+
bindingContext.ModelState.AddModelError(modelName, "Missing Content-Type header.");
25+
bindingContext.Result = ModelBindingResult.Failed();
26+
return;
27+
}
28+
29+
try
30+
{
31+
T? model = null;
32+
33+
if (request.ContentType.StartsWith("multipart/form-data", StringComparison.OrdinalIgnoreCase))
34+
{
35+
var form = await request.ReadFormAsync(bindingContext.HttpContext.RequestAborted);
36+
37+
if (form.TryGetValue("payload", out var payloadJson) && !StringValues.IsNullOrEmpty(payloadJson))
38+
{
39+
model = JsonSerializer.Deserialize<T>(payloadJson.ToString(), _defaultJsonOptions);
40+
}
41+
42+
if (model is IFormFileReceiver receiver)
43+
{
44+
receiver.SetFiles(form.Files);
45+
}
46+
}
47+
else if (request.ContentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase))
48+
{
49+
model = await JsonSerializer.DeserializeAsync<T>(
50+
request.Body,
51+
_defaultJsonOptions,
52+
bindingContext.HttpContext.RequestAborted
53+
);
54+
}
55+
else
56+
{
57+
bindingContext.ModelState.AddModelError(modelName, $"Unsupported Content-Type '{request.ContentType}'.");
58+
bindingContext.Result = ModelBindingResult.Failed();
59+
return;
60+
}
61+
62+
if (model is null)
63+
{
64+
bindingContext.ModelState.AddModelError(modelName, "Could not deserialize the request body.");
65+
bindingContext.Result = ModelBindingResult.Failed();
66+
return;
67+
}
68+
69+
bindingContext.Result = ModelBindingResult.Success(model);
70+
}
71+
catch (JsonException ex)
72+
{
73+
bindingContext.ModelState.AddModelError(bindingContext.ModelName, "Invalid JSON payload.");
74+
bindingContext.Result = ModelBindingResult.Failed();
75+
}
76+
catch (Exception ex)
77+
{
78+
bindingContext.ModelState.AddModelError(bindingContext.ModelName, "Unexpected error during model binding.");
79+
bindingContext.Result = ModelBindingResult.Failed();
80+
}
81+
}
82+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using System;
2+
using Microsoft.AspNetCore.Mvc.ModelBinding;
3+
4+
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders
5+
{
6+
/// <summary>
7+
/// Provides a model binder for parameters annotated with FromFormOrJsonAttribute.
8+
/// </summary>
9+
public class FromFormOrJsonModelBinderProvider : IModelBinderProvider
10+
{
11+
public IModelBinder? GetBinder(ModelBinderProviderContext context)
12+
{
13+
if (context == null)
14+
{
15+
throw new ArgumentNullException(nameof(context));
16+
}
17+
18+
var binderAttribute = context.Metadata.BinderMetadata as FromFormOrJsonAttribute;
19+
if (binderAttribute != null)
20+
{
21+
var binderType = typeof(FromFormOrJsonModelBinder<>).MakeGenericType(context.Metadata.ModelType);
22+
return (IModelBinder?)Activator.CreateInstance(binderType);
23+
}
24+
25+
return null;
26+
}
27+
}
28+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using Microsoft.AspNetCore.Mvc.ModelBinding;
2+
3+
namespace Microsoft.AspNetCore.Mvc;
4+
5+
/// <summary>
6+
/// Indicates that a parameter should be bound using the FromFormOrJsonModelBinder.
7+
/// </summary>
8+
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
9+
public class FromFormOrJsonAttribute : ModelBinderAttribute
10+
{
11+
public FromFormOrJsonAttribute() : base(typeof(FromFormOrJsonModelBinder<>)) { }
12+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
using Microsoft.AspNetCore.Http;
2+
3+
namespace Microsoft.AspNetCore.Mvc.ModelBinding;
4+
5+
/// <summary>
6+
/// Optional interface for models that wish to receive uploaded files from multipart/form-data requests.
7+
/// </summary>
8+
public interface IFormFileReceiver
9+
{
10+
void SetFiles(IFormFileCollection files);
11+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
using System.IO;
2+
using System.Text;
3+
using System.Text.Json;
4+
using System.Threading.Tasks;
5+
using Microsoft.AspNetCore.Http;
6+
using Microsoft.AspNetCore.Http.Internal;
7+
using Microsoft.AspNetCore.Mvc.ModelBinding;
8+
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
9+
using Microsoft.Extensions.Logging;
10+
using Microsoft.Extensions.Logging.Abstractions;
11+
using Xunit;
12+
13+
namespace Microsoft.AspNetCore.Mvc.ModelBinding.Tests;
14+
15+
public class FromFormOrJsonModelBinderTest
16+
{
17+
private static DefaultHttpContext CreateHttpContextWithJson<T>(T payload)
18+
{
19+
var context = new DefaultHttpContext();
20+
context.Request.ContentType = "application/json";
21+
22+
var json = JsonSerializer.Serialize(payload);
23+
var bytes = Encoding.UTF8.GetBytes(json);
24+
context.Request.Body = new MemoryStream(bytes);
25+
26+
return context;
27+
}
28+
29+
private static DefaultHttpContext CreateHttpContextWithForm<T>(T payload)
30+
{
31+
var context = new DefaultHttpContext();
32+
context.Request.ContentType = "multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW";
33+
var form = new FormCollection(new Dictionary<string, Microsoft.Extensions.Primitives.StringValues>
34+
{
35+
{ "payload", JsonSerializer.Serialize(payload) }
36+
});
37+
38+
context.Request.Form = form;
39+
return context;
40+
}
41+
42+
public class SampleDto
43+
{
44+
public string Name { get; set; }
45+
}
46+
47+
[Fact]
48+
public async Task BindsFromJsonPayload()
49+
{
50+
// Arrange
51+
var dto = new SampleDto { Name = "Json Test" };
52+
var httpContext = CreateHttpContextWithJson(dto);
53+
var bindingContext = GetBindingContext<SampleDto>(httpContext);
54+
55+
var binder = new FromFormOrJsonModelBinder<SampleDto>();
56+
57+
// Act
58+
await binder.BindModelAsync(bindingContext);
59+
60+
// Assert
61+
Assert.True(bindingContext.Result.IsModelSet);
62+
var result = Assert.IsType<SampleDto>(bindingContext.Result.Model);
63+
Assert.Equal("Json Test", result.Name);
64+
}
65+
66+
private static DefaultModelBindingContext GetBindingContext<T>(HttpContext httpContext)
67+
{
68+
var metadataProvider = new EmptyModelMetadataProvider();
69+
var modelMetadata = metadataProvider.GetMetadataForType(typeof(T));
70+
return new DefaultModelBindingContext
71+
{
72+
ModelMetadata = modelMetadata,
73+
ModelName = "model",
74+
ModelState = new ModelStateDictionary(),
75+
ValueProvider = new SimpleValueProvider(),
76+
ActionContext = new ActionContext
77+
{
78+
HttpContext = httpContext,
79+
RouteData = new Microsoft.AspNetCore.Routing.RouteData(),
80+
ActionDescriptor = new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor()
81+
},
82+
FieldName = "model",
83+
BindingSource = BindingSource.Custom,
84+
HttpContext = httpContext,
85+
};
86+
}
87+
}

0 commit comments

Comments
 (0)