Unpacking a JSON post model to action parameters

Viewed 300

I am tasked with upgrading a large Framework ASP.NET MVC application to .NET 5. Most of the work is done, but I'm running into an issue with HTTP POST calls from the frontend with JSON objects.

Most of these POST calls have a JSON body like this:

{
   "param1" : "hello",
   "param2" : "world",
   "param3" : 123
}

Its corresponding controller action would look like this:

public ActionResult SaveData(string param1, string param2, int param3)
{
    //Do save stuff
}

This doesn't work in ASP.NET Core. The parameters stay empty. I did find one solution, which is to encapsulate these parameters in a model object and use that as the only parameter with a [FromBody] attribute. However, this application has hundreds of actions with all kinds of parameters and I'm really not looking forward to having to write models for all of these.

So, I'm looking for a way to tell ASP.NET to treat the properties of these JSON objects as parameters for the corresponding action. I've googled around a bit, but couldn't find anything useful. Is there any way to do this, or am I stuck with having to write models for every single action?

2 Answers

A colleague of mine flexed his Google-fu and eventually found this library, which is exactly what I needed.

You can create a custom ValueProvider for this.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Mime;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Net.Http.Headers;
using Newtonsoft.Json;

public class JsonValueProviderFactory : IValueProviderFactory
{
    public async Task CreateValueProviderAsync(ValueProviderFactoryContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        string contentType = context.ActionContext.HttpContext.Request.Headers[HeaderNames.ContentType].FirstOrDefault();

        bool isJson = contentType == null
            ? false
            : contentType.StartsWith(MediaTypeNames.Application.Json, StringComparison.OrdinalIgnoreCase);

        if (isJson)
        {
            context.ActionContext.HttpContext.Request.EnableBuffering();
            using (var reader = new StreamReader(context.ActionContext.HttpContext.Request.Body, Encoding.UTF8, false, 1024, true))
            {
                string body = await reader.ReadToEndAsync();
                var values = JsonConvert.DeserializeObject<Dictionary<string, object>>(body);
                context.ActionContext.HttpContext.Request.Body.Seek(0, SeekOrigin.Begin); // rewind

                var valueProvider = new JsonValueProvider(values);
                context.ValueProviders.Add(valueProvider);
            }
        }
    }
}

//todo: implement better logic for nested objects
public class JsonValueProvider : IValueProvider
{
    private Dictionary<string, object> _values;

    public JsonValueProvider(Dictionary<string, object> values)
    {
        _values = new Dictionary<string, object>(values, StringComparer.OrdinalIgnoreCase);
    }

    public bool ContainsPrefix(string prefix) => _values.ContainsKey(prefix); 

    public ValueProviderResult GetValue(string key)
    {
        return _values.TryGetValue(key, out object value)
            ? new ValueProviderResult(Convert.ToString(value))
            : ValueProviderResult.None;
    }
}

Register JsonValueProviderFactory in Startup.cs

services
    .AddMvc(options =>
    {
        options.ValueProviderFactories.Add(new JsonValueProviderFactory());
        //...
    });

Notes

This implementation supports only plain values and not complex objects, and requires some testing. If you are binding complex object as action parameter do not forget to specify FromBody attribute so default model binding takes place and this value provider doesn't break anything.

Related