Change the JSON deserialization/serialization policy for single ASP.NET Core controller

Solution 1:

The solution on the shared link in your question is OK for serialization (implemented by IOutputFormatter) although we may have another approach by extending it via other extensibility points.

Here I would like to focus on the missing direction (the deserializing direction which is implemented by IInputFormatter). You can implement a custom IModelBinder but it requires you to reimplement the BodyModelBinder and BodyModelBinderProvider which is not easy. Unless you accept to clone all the source code of them and modify the way you want. That's not very friendly to maintainability and getting up-to-date to what changed by the framework.

After researching through the source code, I've found that it's not easy to find a point where you can customize the deserializing behavior based on different controllers (or actions). Basically the default implementation uses a one-time init IInputFormatter for json (default by JsonInputFormatter for asp.net core < 3.0). That in chain will share one instance of JsonSerializerSettings. In your scenario, actually you need multiple instances of that settings (for each controller or action). The easiest point I think is to customize an IInputFormatter (extending the default JsonInputFormatter). It becomes more complicated when the default implementation uses ObjectPool for the instance of JsonSerializer (which is associated with a JsonSerializerSettings). To follow that style of pooling the objects (for better performance), you need a list of object pools (we will use a dictionary here) instead of just one object pool for the shared JsonSerializer as well as the associated JsonSerializerSettings (as implemented by the default JsonInputFormatter).

The point here is to based on the current InputFormatterContext, you need to build the corresponding JsonSerializerSettings as well as the JsonSerializer to be used. That sounds simple but once it comes to a full implementation (with fairly complete design), the code is not short at all. I've designed it into multiple classes. If you really want to see it working, just be patient to copy the code carefully (of course reading it through to understand is recommended). Here's all the code:

public abstract class ContextAwareSerializerJsonInputFormatter : JsonInputFormatter
{        
    public ContextAwareSerializerJsonInputFormatter(ILogger logger, 
        JsonSerializerSettings serializerSettings, 
        ArrayPool<char> charPool, ObjectPoolProvider objectPoolProvider, MvcOptions options, MvcJsonOptions jsonOptions) : base(logger, serializerSettings, charPool, objectPoolProvider, options, jsonOptions)
    {
        PoolProvider = objectPoolProvider;
    }
    readonly AsyncLocal<InputFormatterContext> _currentContextAsyncLocal = new AsyncLocal<InputFormatterContext>();
    readonly AsyncLocal<ActionContext> _currentActionAsyncLocal = new AsyncLocal<ActionContext>();
    protected InputFormatterContext CurrentContext => _currentContextAsyncLocal.Value;
    protected ActionContext CurrentAction => _currentActionAsyncLocal.Value;
    protected ObjectPoolProvider PoolProvider { get; }
    public override Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context, Encoding encoding)
    {
        _currentContextAsyncLocal.Value = context;
        _currentActionAsyncLocal.Value = context.HttpContext.RequestServices.GetRequiredService<IActionContextAccessor>().ActionContext;
        return base.ReadRequestBodyAsync(context, encoding); 
    }
    public override Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context)
    {
        _currentContextAsyncLocal.Value = context;
        _currentActionAsyncLocal.Value = context.HttpContext.RequestServices.GetRequiredService<IActionContextAccessor>().ActionContext;
        return base.ReadRequestBodyAsync(context);
    }
    protected virtual JsonSerializer CreateJsonSerializer(InputFormatterContext context) => null;
    protected override JsonSerializer CreateJsonSerializer()
    {
        var context = CurrentContext;
        return (context == null ? null : CreateJsonSerializer(context)) ?? base.CreateJsonSerializer();
    }
}

public abstract class ContextAwareMultiPooledSerializerJsonInputFormatter : ContextAwareSerializerJsonInputFormatter
{
    public ContextAwareMultiPooledSerializerJsonInputFormatter(ILogger logger, JsonSerializerSettings serializerSettings, ArrayPool<char> charPool, ObjectPoolProvider objectPoolProvider, MvcOptions options, MvcJsonOptions jsonOptions) 
        : base(logger, serializerSettings, charPool, objectPoolProvider, options, jsonOptions)
    {
        
    }
    readonly IDictionary<object, ObjectPool<JsonSerializer>> _serializerPools = new ConcurrentDictionary<object, ObjectPool<JsonSerializer>>();
    readonly AsyncLocal<object> _currentPoolKeyAsyncLocal = new AsyncLocal<object>();
    protected object CurrentPoolKey => _currentPoolKeyAsyncLocal.Value;
    protected abstract object GetSerializerPoolKey(InputFormatterContext context);
    protected override JsonSerializer CreateJsonSerializer(InputFormatterContext context)
    {
        object poolKey = GetSerializerPoolKey(context) ?? "";
        if(!_serializerPools.TryGetValue(poolKey, out var pool))
        {
            //clone the settings
            var serializerSettings = new JsonSerializerSettings();
            foreach(var prop in typeof(JsonSerializerSettings).GetProperties().Where(e => e.CanWrite))
            {
                prop.SetValue(serializerSettings, prop.GetValue(SerializerSettings));
            }
            ConfigureSerializerSettings(serializerSettings, poolKey, context);
            pool = PoolProvider.Create(new JsonSerializerPooledPolicy(serializerSettings));
            _serializerPools[poolKey] = pool;
        }
        _currentPoolKeyAsyncLocal.Value = poolKey;
        return pool.Get();
    }
    protected override void ReleaseJsonSerializer(JsonSerializer serializer)
    {            
        if(_serializerPools.TryGetValue(CurrentPoolKey ?? "", out var pool))
        {
            pool.Return(serializer);
        }         
    }
    protected virtual void ConfigureSerializerSettings(JsonSerializerSettings serializerSettings, object poolKey, InputFormatterContext context) { }
}

//there is a similar class like this implemented by the framework 
//but it's a pity that it's internal
//So we define our own class here (which is exactly the same from the source code)
//It's quite simple like this
public class JsonSerializerPooledPolicy : IPooledObjectPolicy<JsonSerializer>
{
    private readonly JsonSerializerSettings _serializerSettings;
    
    public JsonSerializerPooledPolicy(JsonSerializerSettings serializerSettings)
    {
        _serializerSettings = serializerSettings;
    }

    public JsonSerializer Create() => JsonSerializer.Create(_serializerSettings);
    
    public bool Return(JsonSerializer serializer) => true;
}

public class ControllerBasedJsonInputFormatter : ContextAwareMultiPooledSerializerJsonInputFormatter,
    IControllerBasedJsonSerializerSettingsBuilder
{
    public ControllerBasedJsonInputFormatter(ILogger logger, JsonSerializerSettings serializerSettings, ArrayPool<char> charPool, ObjectPoolProvider objectPoolProvider, MvcOptions options, MvcJsonOptions jsonOptions) : base(logger, serializerSettings, charPool, objectPoolProvider, options, jsonOptions)
    {
    }
    readonly IDictionary<object, Action<JsonSerializerSettings>> _configureSerializerSettings
             = new Dictionary<object, Action<JsonSerializerSettings>>();
    readonly HashSet<object> _beingAppliedConfigurationKeys = new HashSet<object>();
    protected override object GetSerializerPoolKey(InputFormatterContext context)
    {
        var routeValues = context.HttpContext.GetRouteData()?.Values;
        var controllerName = routeValues == null ? null : routeValues["controller"]?.ToString();
        if(controllerName != null && _configureSerializerSettings.ContainsKey(controllerName))
        {
            return controllerName;
        }
        var actionContext = CurrentAction;
        if (actionContext != null && actionContext.ActionDescriptor is ControllerActionDescriptor actionDesc)
        {
            foreach (var attr in actionDesc.MethodInfo.GetCustomAttributes(true)
                                           .Concat(actionDesc.ControllerTypeInfo.GetCustomAttributes(true)))
            {
                var key = attr.GetType();
                if (_configureSerializerSettings.ContainsKey(key))
                {                        
                    return key;
                }
            }
        }
        return null;
    }
    public IControllerBasedJsonSerializerSettingsBuilder ForControllers(params string[] controllerNames)
    {
        foreach(var controllerName in controllerNames ?? Enumerable.Empty<string>())
        {                
            _beingAppliedConfigurationKeys.Add((controllerName ?? "").ToLowerInvariant());
        }            
        return this;
    }
    public IControllerBasedJsonSerializerSettingsBuilder ForControllersWithAttribute<T>()
    {
        _beingAppliedConfigurationKeys.Add(typeof(T));
        return this;
    }
    public IControllerBasedJsonSerializerSettingsBuilder ForActionsWithAttribute<T>()
    {
        _beingAppliedConfigurationKeys.Add(typeof(T));
        return this;
    }
    ControllerBasedJsonInputFormatter IControllerBasedJsonSerializerSettingsBuilder.WithSerializerSettingsConfigurer(Action<JsonSerializerSettings> configurer)
    {
        if (configurer == null) throw new ArgumentNullException(nameof(configurer));
        foreach(var key in _beingAppliedConfigurationKeys)
        {
            _configureSerializerSettings[key] = configurer;
        }
        _beingAppliedConfigurationKeys.Clear();
        return this;
    }
    protected override void ConfigureSerializerSettings(JsonSerializerSettings serializerSettings, object poolKey, InputFormatterContext context)
    {            
        if (_configureSerializerSettings.TryGetValue(poolKey, out var configurer))
        {
            configurer.Invoke(serializerSettings);
        }
    }
}
public interface IControllerBasedJsonSerializerSettingsBuilder
{
    ControllerBasedJsonInputFormatter WithSerializerSettingsConfigurer(Action<JsonSerializerSettings> configurer);
    IControllerBasedJsonSerializerSettingsBuilder ForControllers(params string[] controllerNames);
    IControllerBasedJsonSerializerSettingsBuilder ForControllersWithAttribute<T>();
    IControllerBasedJsonSerializerSettingsBuilder ForActionsWithAttribute<T>();
}

To help conveniently configure the services to replace the default JsonInputFormatter, we have the following code:

public class ControllerBasedJsonInputFormatterMvcOptionsSetup : IConfigureOptions<MvcOptions>
{
    private readonly ILoggerFactory _loggerFactory;
    private readonly MvcJsonOptions _jsonOptions;
    private readonly ArrayPool<char> _charPool;
    private readonly ObjectPoolProvider _objectPoolProvider;
    public ControllerBasedJsonInputFormatterMvcOptionsSetup(
        ILoggerFactory loggerFactory,
        IOptions<MvcJsonOptions> jsonOptions,
        ArrayPool<char> charPool,
        ObjectPoolProvider objectPoolProvider)
    {
        if (loggerFactory == null)
        {
            throw new ArgumentNullException(nameof(loggerFactory));
        }

        if (jsonOptions == null)
        {
            throw new ArgumentNullException(nameof(jsonOptions));
        }

        if (charPool == null)
        {
            throw new ArgumentNullException(nameof(charPool));
        }

        if (objectPoolProvider == null)
        {
            throw new ArgumentNullException(nameof(objectPoolProvider));
        }

        _loggerFactory = loggerFactory;
        _jsonOptions = jsonOptions.Value;
        _charPool = charPool;
        _objectPoolProvider = objectPoolProvider;
    }
    public void Configure(MvcOptions options)
    {
        //remove the default
        options.InputFormatters.RemoveType<JsonInputFormatter>();
        //add our own
        var jsonInputLogger = _loggerFactory.CreateLogger<ControllerBasedJsonInputFormatter>();

        options.InputFormatters.Add(new ControllerBasedJsonInputFormatter(
            jsonInputLogger,
            _jsonOptions.SerializerSettings,
            _charPool,
            _objectPoolProvider,
            options,
            _jsonOptions));
    }
}
public static class ControllerBasedJsonInputFormatterServiceCollectionExtensions
{
    public static IServiceCollection AddControllerBasedJsonInputFormatter(this IServiceCollection services,
        Action<ControllerBasedJsonInputFormatter> configureFormatter)
    {
        if(configureFormatter == null)
        {
            throw new ArgumentNullException(nameof(configureFormatter));
        }
        services.TryAddSingleton<IActionContextAccessor, ActionContextAccessor>();
        return services.ConfigureOptions<ControllerBasedJsonInputFormatterMvcOptionsSetup>()
                       .PostConfigure<MvcOptions>(o => {
                           var jsonInputFormatter = o.InputFormatters.OfType<ControllerBasedJsonInputFormatter>().FirstOrDefault();
                           if(jsonInputFormatter != null)
                           {
                               configureFormatter(jsonInputFormatter);
                           }
                       });
    }
}

//This attribute is used as a marker to decorate any controllers 
//or actions that you want to apply your custom input formatter
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class UseSnakeCaseJsonInputFormatterAttribute : Attribute
{
}

Finally here's a sample configuration code:

//inside Startup.ConfigureServices
services.AddControllerBasedJsonInputFormatter(formatter => {
            formatter.ForControllersWithAttribute<UseSnakeCaseJsonInputFormatterAttribute>()
                     .ForActionsWithAttribute<UseSnakeCaseJsonInputFormatterAttribute>()
                     .WithSerializerSettingsConfigurer(settings => {
                        var contractResolver = settings.ContractResolver as DefaultContractResolver ?? new DefaultContractResolver();
                        contractResolver.NamingStrategy = new SnakeCaseNamingStrategy();
                        settings.ContractResolver = contractResolver;
                     });
        });

Now you can use the marker attribute UseSnakeCaseJsonInputFormatterAttribute on any controllers (or action methods) that you want to apply the snake-case json input formatter, like this:

[UseSnakeCaseJsonInputFormatter]
public class YourController : Controller {
     //...
}

Note that the code above uses asp.net core 2.2, for asp.net core 3.0+, you can replace the JsonInputFormatter with NewtonsoftJsonInputFormatter and MvcJsonOptions with MvcNewtonsoftJsonOptions.