Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

ASP.NET Core and formdata binding with file and json property

Tags:

json

c#

asp.net

I have the following model:

public class MyJson {
    public string Test{get;set;}
}
    
public class Dto {
    public IFormFile MyFile {get;set;}
    public MyJson MyJson {get;set;}
}

On the client side I want to send a file and a JSON obj, so I send it in the formData with the following keys:

var formData = new FormData();
formData["myFile"] = file; // here is my file
formData["myJson"] = obj;  // object to be serialized to json.

My action looks like this:

public void MyAction(Dto dto) // or with [FromForm], doesn't really matter
{
  //dto.MyJson is null here
  //dto.myFile is set correctly.
}

If I change dto.MyJson to be a string, then it works perfectly fine. However, I have to deserialize it into my object manually in the action. The second issue with having it as a string, is that I can't use swagger UI to handle it properly, because it will ask me for a JSON string instead of an object. Anyway, having it as a string just doesn't sound right.

Is there a native way to handle JSON and file properly in action parameters instead of parsing it manually with Request.Form?

like image 696
MistyK Avatar asked Jul 10 '17 16:07

MistyK


1 Answers

This can be accomplished using a custom model binder:

public class FormDataJsonBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if(bindingContext == null) throw new ArgumentNullException(nameof(bindingContext));

        // Fetch the value of the argument by name and set it to the model state
        string fieldName = bindingContext.FieldName;
        var valueProviderResult = bindingContext.ValueProvider.GetValue(fieldName);
        if(valueProviderResult == ValueProviderResult.None) return Task.CompletedTask;
        else bindingContext.ModelState.SetModelValue(fieldName, valueProviderResult);

        // Do nothing if the value is null or empty
        string value = valueProviderResult.FirstValue;
        if(string.IsNullOrEmpty(value)) return Task.CompletedTask;

        try
        {
            // Deserialize the provided value and set the binding result
            object result = JsonConvert.DeserializeObject(value, bindingContext.ModelType);
            bindingContext.Result = ModelBindingResult.Success(result);
        }
        catch(JsonException)
        {
            bindingContext.Result = ModelBindingResult.Failed();
        }

        return Task.CompletedTask;
    }
}

You can then use the ModelBinder attribute in your DTO class to indicate that this binder should be used to bind the MyJson property:

public class Dto
{
    public IFormFile MyFile {get;set;}

    [ModelBinder(BinderType = typeof(FormDataJsonBinder))]
    public MyJson MyJson {get;set;}
}

Note that you also need to serialize your JSON data from correctly in the client:

const formData = new FormData();
formData.append(`myFile`, file);
formData.append('myJson', JSON.stringify(obj));

The above code will work, but you can also go a step further and define a custom attribute and a custom IModelBinderProvider so you don't need to use the more verbose ModelBinder attribute each time you want to do this. Note that I have re-used the existing [FromForm] attribute for this, but you could also define your own attribute to use instead.

public class FormDataJsonBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if(context == null) throw new ArgumentNullException(nameof(context));

        // Do not use this provider for binding simple values
        if(!context.Metadata.IsComplexType) return null;

        // Do not use this provider if the binding target is not a property
        var propName = context.Metadata.PropertyName;
        var propInfo = context.Metadata.ContainerType?.GetProperty(propName);
        if(propName == null || propInfo == null) return null;

        // Do not use this provider if the target property type implements IFormFile
        if(propInfo.PropertyType.IsAssignableFrom(typeof(IFormFile))) return null;

        // Do not use this provider if this property does not have the FromForm attribute
        if(!propInfo.GetCustomAttributes(typeof(FromForm), false).Any()) return null;

        // All criteria met; use the FormDataJsonBinder
        return new FormDataJsonBinder();
    }
}

You will need to add this model binder provider to your startup config before it will be picked up:

services.AddMvc(options =>
{
    // add custom model binders to beginning of collection
    options.ModelBinderProviders.Insert(0, new FormDataJsonBinderProvider())
});

Then your DTO can be a bit simpler:

public class Dto
{
    public IFormFile MyFile {get;set;}

    [FromForm]
    public MyJson MyJson {get;set;}
}

You can read more about custom model binding in the ASP.NET Core documentation: https://learn.microsoft.com/en-us/aspnet/core/mvc/advanced/custom-model-binding

like image 171
mark.monteiro Avatar answered Nov 08 '22 07:11

mark.monteiro