Search code examples
asp.net-coreasp.net-core-mvcmodel-bindingcustom-model-binder

ASP.NET Core MVC: model binds all POSTed key values to a non-name matching property in addition to default model binding?


I am trying to get the raw POSTed key value pairs for additional processing logic later on in my action.

I have a class like this:

public class ValidationRequest<T>
    where T : class
{
    public string Form { get; set; } = string.Empty;
    
    public required T Fields { get; set; }
    public Dictionary<string, string> RawPostedKeyValues { get; set; } = [];
}

If I post to an action with this model as the input:

[HttpPost]
public async Task<JsonResult> ValidateTestFormAsync([FromBody] ValidationRequest<PersonModel> personValidationRequest)
{
    //do things here
}

Where PersonModel could be any "regular" model you would normally pass via post into the MVC action.

I would like the property RawPostedKeyValues to include all the raw posted key values that match the property names of whatever type fields is (to prevent overposting abuse) when the model is bound in addition to doing the normal default model binding and validation.

I'm trying not to re-invent the whole wheel here and use as much default MVC convention as possible.

So if I posted this:

{
  "form": "CreatePerson",
  "fields": {
    "Id": null,
    "FirstName": "Joe",
    "LastName": "Bob",
    "Age": null,
    "LikesChocolate": false,
    "Hobbies": []
  }
}

And I just spit out the posted model back out as JSON in the response, it would be this:

{
  "form": "CreatePerson",
  "fields": {
    "Id": null,
    "FirstName": "Joe",
    "LastName": "Bob",
    "Age": null,
    "LikesChocolate": false
  }
  "rawPostedKeyValues:[
    { "key": "Fields.Id", "value": null },
    { "key": "Fields.FirstName", "value": "Joe"},
    { "key": "Fields.LastName", "value": "Bob"},
    { "key": "Fields.Age", "value": null},
    { "key": "Fields.LikeChocolate", "value": "false"},
  ]
}

The key names use the default naming convention based on the ModelState's validation errors; I want to keep that.

Now it seems like the best way to handle this would be a custom JSON converter or model binder. I tried this as can be see in the ValidationRequest class, but that won't work at the property level because there is no key matching the property name RawPostedKeyValues in the POST data, so my custom logic never gets called.

It seems I would need to use a custom model binder on the top level ValidationRequest class? How would I go about reading the body to bind that property while also still allowing the default model binding to occur? How would I read the request body to populate those properties?

EDIT:

I was able to use the answer below, and combine it with the factory pattern to get it working. You need to use the factory pattern because of the generic type that won't be known until run time.

Here is the full converter:

public class ValidationRequestJsonConverter : JsonConverterFactory
{
    public override bool CanConvert(Type typeToConvert)
    {
        if (!typeToConvert.IsGenericType)
            return false;

        Type generic = typeToConvert.GetGenericTypeDefinition();
        return (generic == typeof(ValidationRequest<>));
    }

    public override JsonConverter CreateConverter(Type type, JsonSerializerOptions options)
    {
        Type argumentType = type.GetGenericArguments()[0];

        JsonConverter converter = (JsonConverter)Activator.CreateInstance(
            typeof(ValidationRequestInnerJsonConverter<>).MakeGenericType(new Type[] { argumentType }),
            BindingFlags.Instance | BindingFlags.Public,
            binder: null,
            args: [options],
            culture: null)!;

        return converter;
    }

}

internal class ValidationRequestInnerJsonConverter<T> : JsonConverter<ValidationRequest<T>>
    where T : class
{
    //Required or will throw erro a runtime error
    public ValidationRequestInnerJsonConverter(JsonSerializerOptions options) { }

    public override ValidationRequest<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var jsonDocument = JsonDocument.ParseValue(ref reader);
        var root = jsonDocument.RootElement;

        var form = root.GetProperty("form").GetString();
        var fieldsJson = root.GetProperty("fields").GetRawText();

        var fieldObject = JsonSerializer.Deserialize<T>(fieldsJson, options) ?? Activator.CreateInstance<T>();

        var rawPostedKeyValues = new Dictionary<string, string?>();
        foreach (var property in root.GetProperty("fields").EnumerateObject())
        {
            rawPostedKeyValues[$"Fields.{property.Name}"] = property.Value.ToString();
        }

        return new ValidationRequest<T>
        {
            Form = form,
            Fields = fieldObject,
            RawPostedKeyValues = rawPostedKeyValues
        };
    }

    public override void Write(Utf8JsonWriter writer, ValidationRequest<T> value, JsonSerializerOptions options)
    {
        writer.WriteStartObject();

        writer.WriteString("form", value.Form);

        writer.WritePropertyName("fields");
        JsonSerializer.Serialize(writer, value.Fields, options);

        writer.WritePropertyName("rawPostedKeyValues");
        JsonSerializer.Serialize(writer, value.RawPostedKeyValues, options);

        writer.WriteEndObject();
    }
}

Solution

  • It seems I would need to use a custom model binder on the top level ValidationRequest class? How would I go about reading the body to bind that property while also still allowing the default model binding to occur? How would I read the request body to populate those properties?

    Well accordingly to your scenario, you could use Custom JSON Converters. The main goal is to populate the ValidationRequest<T> object, including the Fields property and the RawPostedKeyValues dictionary.

    Within the jsonConverter class first read the raw json data from the request and extract the property by using root.GetProperty("form") which will provide us the value of the form property from the JSON and stores it in a string variable.

    Then we have to extract and deserialize the fields property which we got earlier.

    Finally, initiate the Dictionary and iterate over the root.GetProperty("fields").EnumerateObject() and bind the ValidationRequest object.

    Apart from that, we would use one additional middleware becuase By default, the ASP.NET Core pipeline reads the request body as a stream. Once the stream is read, it is not automatically reset or reusable. This means that if any part of the middleware pipeline reads the request body (e.g., for logging or validation), it cannot be read again by the model binders or action methods unless specifically handled.

    So in order to overcome above issue, we would use middleware which enables buffering of the request body, allowing the request data to be read multiple times.

    Let's have a look in practice:

    Model:

    public class PersonModel
    {
        public int? Id { get; set; }
        public string FirstName { get; set; } = string.Empty;
        public string LastName { get; set; } = string.Empty;
        public int? Age { get; set; }
        public bool LikesChocolate { get; set; }
        public List<string> Hobbies { get; set; } = new List<string>();
    }
    

    Request Validator:

    [JsonConverter(typeof(ValidationRequestJsonConverter<PersonModel>))]
    public class ValidationRequest<T> where T : class
    {
        public string Form { get; set; } = string.Empty;
        public required T Fields { get; set; }
        public Dictionary<string, string?> RawPostedKeyValues { get; set; } = new Dictionary<string, string?>();
    }
    

    Json Request Converter:

    public class ValidationRequestJsonConverter<T> : JsonConverter<ValidationRequest<T>>
    where T : class
    {
        public override ValidationRequest<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            var jsonDocument = JsonDocument.ParseValue(ref reader);
            var root = jsonDocument.RootElement;
    
            var form = root.GetProperty("form").GetString();
            var fieldsJson = root.GetProperty("fields").GetRawText();
    
            var fields = JsonSerializer.Deserialize<T>(fieldsJson, options) ?? Activator.CreateInstance<T>();
    
            var rawPostedKeyValues = new Dictionary<string, string?>();
            foreach (var property in root.GetProperty("fields").EnumerateObject())
            {
                rawPostedKeyValues[$"Fields.{property.Name}"] = property.Value.ToString();
            }
    
            return new ValidationRequest<T>
            {
                Form = form,
                Fields = fields,
                RawPostedKeyValues = rawPostedKeyValues
            };
        }
    
        public override void Write(Utf8JsonWriter writer, ValidationRequest<T> value, JsonSerializerOptions options)
        {
            writer.WriteStartObject();
    
            writer.WriteString("form", value.Form);
    
            writer.WritePropertyName("fields");
            JsonSerializer.Serialize(writer, value.Fields, options);
    
            writer.WritePropertyName("rawPostedKeyValues");
            JsonSerializer.Serialize(writer, value.RawPostedKeyValues, options);
    
            writer.WriteEndObject();
        }
    }
    

    Middleware for multiple request body pipeline:

    public class RawDataCaptureMiddleware
    {
        private readonly RequestDelegate _next;
    
        public RawDataCaptureMiddleware(RequestDelegate next)
        {
            _next = next;
        }
    
        public async Task InvokeAsync(HttpContext context)
        {
          
            context.Request.EnableBuffering();
    
            var requestBodyStream = new StreamReader(context.Request.Body);
            var requestBodyText = await requestBodyStream.ReadToEndAsync();
            context.Request.Body.Position = 0; 
    
            if (string.IsNullOrWhiteSpace(requestBodyText))
            {
                context.Items["RawRequestData"] = null;
            }
            else
            {
                try
                {
                    var requestData = JsonDocument.Parse(requestBodyText);
                    context.Items["RawRequestData"] = requestData;
                }
                catch (JsonException ex)
                {
                    context.Items["RawRequestData"] = null;
                    
                }
            }
           
    
            await _next(context);
        }
    }
    

    Program.cs:

    app.UseAuthorization();
    
    app.UseMiddleware<RawDataCaptureMiddleware>();
    
    app.MapControllerRoute(
        name: "default",
        pattern: "{controller=Home}/{action=Index}/{id?}");
    
    app.Run();
    

    Output:

    enter image description here

    enter image description here

    enter image description here

    Note: If you need any further example please refer to this official document. You could also check custom model binding as well.