Search code examples
c#asp.net-corerazor

How to have an action to support form-data and as JSON in the body?


I have a controller action:

[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> Register(RegisterViewModel model, string returnUrl = null) { }

I need to be able to call this endpoint either through a razor page or using the HttpClient's PostAsJsonAsync.

I got this working for the razor page. But when I do:

var response = await client.PostAsJsonAsync($"{api}Account/Register", new { email, password });

I get a 415 Unsupported Media Type.

How can I support both ways of submitting the data?


Solution

  • The new behavior of model binding designed in asp.net core is a bit inconvenient in some cases (for some developers) but I personally think it's necessary to reduce some complexity in the binding code (which can improve the performance a bit). The new behavior of model binding reflects clearly the purpose of the endpoints (MVC vs Web API). So usually your web browsers don't consume Web API directly (in case you use it, you must follow its rule, e.g: post the request body with application/json content-type).

    If you look into the source code of asp.net core, you may have the best work-around or solution to customize it to support the multi-source model binding again. I did not look into any source code but I've come up with a simple solution to support model binding from either form or request body like this. Note that it will bind data from either the form or request body, not combined from both.

    The idea is just based on implementing a custom IModelBinder and IModelBinderProvider. You can wrap the default one (following the decorator pattern) and add custom logic around. Otherwise you need to re-implement the whole logic.

    Here is the code

    //the custom IModelBinder, this supports binding data from either form or request body
    public class FormBodyModelBinder : IModelBinder
    {
        readonly IModelBinder _overriddenModelBinder;
        public FormBodyModelBinder(IModelBinder overriddenModelBinder)
        {
            _overriddenModelBinder = overriddenModelBinder;
        }
        public async Task BindModelAsync(ModelBindingContext bindingContext)
        {
            var httpContext = bindingContext.HttpContext;
            var contentType = httpContext.Request.ContentType;
            var hasFormData = httpContext.Request.HasFormContentType;
            var hasJsonData = contentType?.Contains("application/json") ?? false;            
            var bindFromBothBodyAndForm = bindingContext.ModelMetadata is DefaultModelMetadata mmd &&
                                         (mmd.Attributes.Attributes?.Any(e => e is FromBodyAttribute) ?? false) &&
                                         (mmd.Attributes.Attributes?.Any(e => e is FromFormAttribute) ?? false);
            if (hasFormData || !hasJsonData || !bindFromBothBodyAndForm)
            {                
                await _overriddenModelBinder.BindModelAsync(bindingContext);
            }
            else //try binding model from the request body (deserialize)
            {
                try
                {
                    //save the request body in HttpContext.Items to support binding multiple times
                    //for multiple arguments
                    const string BODY_KEY = "___request_body";
                    if (!httpContext.Items.TryGetValue(BODY_KEY, out var body) || !(body is string jsonPayload))
                    {
                        using (var streamReader = new StreamReader(httpContext.Request.Body))
                        {
                            jsonPayload = await streamReader.ReadToEndAsync();
                        }
                        httpContext.Items[BODY_KEY] = jsonPayload;
                    }
                    bindingContext.Result = ModelBindingResult.Success(JsonSerializer.Deserialize(jsonPayload, bindingContext.ModelType));
    
                }
                catch
                {
                    bindingContext.Result = ModelBindingResult.Success(Activator.CreateInstance(bindingContext.ModelType));
                }
            }
        }
    }
    
    //the corresponding model binder provider
    public class FormBodyModelBinderProvider : IModelBinderProvider
    {
        readonly IModelBinderProvider _overriddenModelBinderProvider;
        public FormBodyModelBinderProvider(IModelBinderProvider overriddenModelBinderProvider)
        {
            _overriddenModelBinderProvider = overriddenModelBinderProvider;
        }
        public IModelBinder GetBinder(ModelBinderProviderContext context)
        {
            var modelBinder = _overriddenModelBinderProvider.GetBinder(context);
            if (modelBinder == null) return null;
            return new FormBodyModelBinder(modelBinder);
        }
    }
    

    We write a simple extension method on MvcOptions to help configure it to override an IModelBinderProvider with the custom one to support multisource model binding.

    public static class MvcOptionsExtensions
    {
        public static void UseFormBodyModelBinderProviderInsteadOf<T>(this MvcOptions mvcOptions) where T : IModelBinderProvider
        {
            var replacedModelBinderProvider = mvcOptions.ModelBinderProviders.OfType<T>().FirstOrDefault();
            if (replacedModelBinderProvider != null)
            {
                var customProvider = new FormBodyModelBinderProvider(replacedModelBinderProvider);
                mvcOptions.ModelBinderProviders.Remove(replacedModelBinderProvider);
                mvcOptions.ModelBinderProviders.Add(customProvider);
            }
        }
    }
    

    To support binding complex model from either form or request body, we can override the ComplexTypeModelBinderProvider like this:

    //in the ConfigureServices method
    services.AddMvc(o => {
                o.UseFormBodyModelBinderProviderInsteadOf<ComplexTypeModelBinderProvider>();
            };
    

    That should suit most of the cases in which your action's argument is of a complex type. Note that the code in the FormBodyModelBinder requires the action arguments to be decorated with both FromFormAttribute and FromBodyAttribute. You can read the code and see where they're fit in. So you can write your own attribute to use instead. I prefer to using existing classes. However in this case, there is an important note about the order of FromFormAttribute and FromBodyAttribute. The FromFormAttribute should be placed before FromBodyAttribute. Per my test, looks like the ASP.NET Core model binding takes the first attribute as effective (seemingly ignores the others), so if FromBodyAttribute is placed first, it will take effect and may prevent all the model binders (including our custom one) from running when the content-type is not supported (returning 415 response).

    The final note about this solution, it's not perfect. Once you accept to bind model from multi-sources like this, it will not handle the case of not supporting media type nicely as when using FromBodyAttribute explicitly. Because when we support multi-source model binding, the FromBodyAttribute is not used and the default check is not kicked in. We must implement that ourselves. Because of multiple binders joining in the binding process, we cannot easily decide when the request becomes not supported (so even you add a check in the FormBodyModelBinder, you can successfully set the response's status code to 415 but it may not be right and the request is still being processed in the selected action method). With this note, in case of media type not supported, you should ensure that your code in the action method is not broken by handling empty (or null) model argument.

    Here is how you use the custom model binder:

    [HttpPost]
    [AllowAnonymous]
    public async Task<IActionResult> Register([FromForm] [FromBody] RegisterViewModel model, string returnUrl = null) { }