.Net Core Api: getting data in a request from different sources

.Net Core has a built-in Model Binding mechanism, which allows not only to accept input parameters in controllers, but to immediately receive objects with filled fields. This allows you to embed all the necessary checks into such an object using Model Validation.

Here are just the data needed for the API to work, come to us not only from Query or Body. Some data needs to be received from Headers (in my case there was json in base64), some data should be from external services or ActionRoute if you use REST. You can use your Binding to get data from there. True, there is a problem: if you decide not to break encapsulation and initialize the model through the constructor, you will have to shaman.

For myself and for future generations, I decided to write something like instructions for using Binding and shamanism with it.

Problem


A typical controller looks something like this:

[HttpGet]
public async Task<IActionResult> GetSomeData([FromQuery[IncomeData someData)
{
    var moreData = GetFromHeaderAndDecode("X-Property");
    if (moreData.Id == 0)
    {
        return StatusCode(400, "Nginx doesnt know your id");
    }
    var externalData = GetFromExternalService("http://myservice.com/MoreData");
    if (externalData == null)
    {
        return StatusCode(500, "Cant connect to external service");
    }
    var finalData = new FinalData(someData, moreData, externalData);
    return _myService.Handle(finalData);
}

As a result, we get the following problems:

  1. The validation logic is smeared by the request object, the request method from the header, the request method from the service, and the controller method. To make sure that the necessary check is definitely there, you need to conduct a whole investigation!
  2. The adjacent controller method will have exactly the same code. Copy-paste programming in attack.
  3. Usually there are much more checks than in the example, and as a result, the only significant line - the call to the business logic processing method - is hidden in a heap of code. To see him and understand what is happening here in general requires some effort.

Custom Binding (Easy Mode)


Part of the problem can be solved by implementing your handler in the request processing pipeline. To do this, first correct our controller by passing the final object to the method immediately. It looks much better, right?

[HttpGet]
public async Task<IActionResult> GetSomeData([FromQuery]FinalData finalData)
{
    return _myService.Handle(finalData);
}

Next, create your binder for the type MoreData.

public class MoreDataBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var moreData = GetFromHeaderAndDecode(bindingContext.HttpContext.Request.Headers);
        if (moreData != null)
        {
            bindingContext.Result = ModelBindingResult.Success(moreData);
        }
        return Task.CompletedTask;
    }
    private MoreData GetFromHeaderAndDecode(IHeaderDictionary headers) { ... }
}

Finally, we’ll fix the FinalData model by adding the binder binding to the property there:

public class FinalData
{
    public int SomeDataNumber { get; set; }

    public string SomeDataText { get; set; }

    [ModelBinder(BinderType = typeof(MoreDataBinder))]
    public MoreData MoreData { get; set; }
}

It’s already better, but hemorrhoids have increased: now you need to know that we have a special handler and indicate it in all models. But it is solvable.

Create your BinderProvider:

public class MoreDataBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        var modelType = context.Metadata.UnderlyingOrModelType;
        if (modelType == typeof(MoreData))
        {
            return new BinderTypeModelBinder(typeof(MoreDataBinder));
        }
        return null;
    }
}

And register it in Startup:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddMvc(options =>
    {
        options.ModelBinderProviders.Insert(0, new MoreDataBinderProvider());
     });
}

The provider is called for each model object in the order of the queue. If our provider meets the desired type, it will return the desired binder. And if not, then the default binder will work. So now whenever we specify the type of MoreData, it will be taken and decoded from Header and special attributes in the models do not need to be specified.

Custom Binding (Hard Mode)


All this is great, but there is one thing but: for the magic to work, our model must have public properties with set. But what about encapsulation? What if I want to transfer the request data to various places in the cloud and know that they will not be changed there?

The problem is that the default binder does not work for models that do not have a default constructor. But what prevents us from writing our own?
The service for which I wrote this code does not use REST, the parameters are transmitted only through Query and Body, and only two types of requests are used - Get
and Post. Accordingly, in the case of the REST API, the processing logic will be slightly different.
In general, the code will remain unchanged, only our binder needs refinement so that it creates an object and fills its private fields. Further I will give pieces of code with comments, who are not interested - at the end of the article under the cat is the entire listing of the class.

First, let's determine if MoreData is the only property of the class. If yes, then you need to create the object yourself (hello, Activator), and if not, then JsonConvert will do the job perfectly, and we just slip the necessary data into the property.

private static bool NeedActivator(IReflect modelType)
{
    var propFlags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance;
    var properties = modelType.GetProperties(propFlags);

    return properties.Select(p => p.Name).Distinct().Count() == 1;
}

Creating an object through JsonConvert is simple, for queries with Body:

private static object? GetModelFromBody(ModelBindingContext bindingContext, Type modelType)
{
    using var reader = new StreamReader(bindingContext.HttpContext.Request.Body);
    var jsonString = reader.ReadToEnd();
    var data = JsonConvert.DeserializeObject(jsonString, modelType);
    return data;
}

But with Query I had to lay down. I would be glad if someone can suggest a more beautiful solution.

When passing an array, several parameters with the same name are obtained. Casting to a flat type helps, but serialization puts extra quotes on the array [], which must be removed manually.

private static object? GetModelFromQuery(ModelBindingContext bindingContext, Type modelType)
{
    var valuesDictionary = QueryHelpers.ParseQuery(bindingContext.HttpContext.Request.QueryString.Value);
    var jsonDictionary = valuesDictionary.ToDictionary(pair => pair.Key, pair => pair.Value.Count < 2 ? pair.Value.ToString() : $"[{pair.Value}]");

    var jsonStr = JsonConvert.SerializeObject(jsonDictionary).Replace("\"[", "[").Replace("]\"", "]");
    var data = JsonConvert.DeserializeObject(jsonStr, modelType);
    return data;
}

Finally, creating the object, it is necessary to write our data into its private property. It was about this shamanism that I spoke at the beginning of the article. I found this solution here , for which many thanks to the author.

private void ForceSetValue(PropertyInfo propertyInfo, object obj, object value)
{
    var propName = $"<{propertyInfo.Name}>k__BackingField";
    var propFlags = BindingFlags.Instance | BindingFlags.NonPublic;
            
    obj.GetType().GetField(propName, propFlags)?.SetValue(obj, value);
}

Well, we combine these methods in a single call:

public Task BindModelAsync(ModelBindingContext bindingContext)
{
    var moreData = GetFromHeaderAndDecode(bindingContext.HttpContext.Request.Headers);
    if (moreData == null)
    {
        return Task.CompletedTask;
    }

    var modelType = bindingContext.ModelMetadata.UnderlyingOrModelType;
    if (NeedActivator(modelType))
    {
        var data = Activator.CreateInstance(modelType, moreData);
        bindingContext.Result = ModelBindingResult.Success(data);

        return Task.CompletedTask;
    }

    var model = bindingContext.HttpContext.Request.Method == "GET"
                            ? GetModelFromQuery(bindingContext, modelType)
                            : GetModelFromBody(bindingContext, modelType);

    if (model is null)
    {
        throw new Exception("  ");
    }

    var ignoreCase = StringComparison.InvariantCultureIgnoreCase;
    var dataProperty = modelType.GetProperties()
                            .FirstOrDefault(p => p.Name.Equals(typeof(T).Name, ignoreCase));

    if (dataProperty != null)
    {
        ForceSetValue(dataProperty, model, moreData);
    }

    bindingContext.Result = ModelBindingResult.Success(model);
    return Task.CompletedTask;
}

It remains to fix BinderProvider so that it responds to any classes with the desired property:

public class MoreDataBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
       var modelType = context.Metadata.UnderlyingOrModelType;
       if (HasDataProperty(modelType))
       {
           return new BinderTypeModelBinder(typeof(PrivateDataBinder<MoreData>));
       }
       return null;
    }
    private bool HasDataProperty(IReflect modelType)
    {
        var propFlags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance;
        var properties = modelType.GetProperties(propFlags);

        return properties.Select(p => p.Name) .Contains(nameof(MoreData));
    }
}


That's all. Binder turned out to be a bit more complicated than in Easy Mode, but now we can bind “external” properties in all methods of all controllers without additional efforts. Of the minuses:

  1. The constructor of objects with private fields must specify the [JsonConstrustor] attribute. But this completely fits into the logic of the model and does not interfere with its perception.
  2. Somewhere, you may need to get MoreData not from the header. But this is treated by creating a separate class.
  3. The rest of the team must be aware of the presence of magic. But the documentation will save humanity.

A complete listing of the resulting Binder is here:

PrivateMoreDataBinder.cs
public class PrivateDataBinder<T> : IModelBinder
    {
        /// <summary></summary>
        /// <param name="bindingContext"></param>
        public Task BindModelAsync(ModelBindingContext bindingContext)
        {
            var moreData = GetFromHeaderAndDecode(bindingContext.HttpContext.Request.Headers);
            if (moreData == null)
            {
                return Task.CompletedTask;
            }

            var modelType = bindingContext.ModelMetadata.UnderlyingOrModelType;
            if (NeedActivator(modelType))
            {
                var data = Activator.CreateInstance(modelType, moreData);
                bindingContext.Result = ModelBindingResult.Success(data);

                return Task.CompletedTask;
            }

            var model = bindingContext.HttpContext.Request.Method == "GET"
                            ? GetModelFromQuery(bindingContext, modelType)
                            : GetModelFromBody(bindingContext, modelType);

            if (model is null)
            {
                throw new Exception("  ");
            }

            var ignoreCase = StringComparison.InvariantCultureIgnoreCase;
            var dataProperty = modelType.GetProperties()
                                        .FirstOrDefault(p => p.Name.Equals(typeof(T).Name, ignoreCase));

            if (dataProperty != null)
            {
                ForceSetValue(dataProperty, model, moreData);
            }

            bindingContext.Result = ModelBindingResult.Success(model);

            return Task.CompletedTask;
        }

        private static object? GetModelFromQuery(ModelBindingContext bindingContext,
                                                 Type modelType)
        {
            var valuesDictionary = QueryHelpers.ParseQuery(bindingContext.HttpContext.Request.QueryString.Value);

            var jsonDictionary = valuesDictionary.ToDictionary(pair => pair.Key, pair => pair.Value.Count < 2 ? pair.Value.ToString() : $"[{pair.Value}]");

            var jsonStr = JsonConvert.SerializeObject(jsonDictionary)
                                     .Replace("\"[", "[")
                                     .Replace("]\"", "]");

            var data = JsonConvert.DeserializeObject(jsonStr, modelType);

            return data;
        }

        private static object? GetModelFromBody(ModelBindingContext bindingContext,
                                                Type modelType)
        {
            using var reader = new StreamReader(bindingContext.HttpContext.Request.Body);
            var jsonString = reader.ReadToEnd();
            var data = JsonConvert.DeserializeObject(jsonString, modelType);

            return data;
        }

        private static bool NeedActivator(IReflect modelType)
        {
            var propFlags = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance;
            var properties = modelType.GetProperties(propFlags);

            return properties.Select(p => p.Name).Distinct().Count() == 1;
        }

        private void ForceSetValue(PropertyInfo propertyInfo, object obj, object value)
        {
            var propName = $"<{propertyInfo.Name}>k__BackingField";
            var propFlags = BindingFlags.Instance | BindingFlags.NonPublic;
            
            obj.GetType().GetField(propName, propFlags)?.SetValue(obj, value);
        }

        private T GetFromHeaderAndDecode(IHeaderDictionary headers) { return (T)Activator.CreateInstance(typeof(T), new object[] { "ok" }); }
    }


All Articles