11

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?

Pexers
  • 953
  • 1
  • 7
  • 20
MistyK
  • 6,055
  • 2
  • 42
  • 76
  • IFormFile is something what cannot be instantiated during parsing from serialized data. – Maxim Jul 10 '17 at 17:03
  • @Maxim IFormFile is a part of ASP.Net Core and it's handled properly by asp.net core mechanisms. The issue here is the json argument. – MistyK Jul 10 '17 at 17:04
  • OK I see.. Maybe the reason is 2 different serialization formats... So it tries to deserialize JSON property with form data formatter. Is it possible as workaround to use json only? – Maxim Jul 10 '17 at 17:10
  • @Maxim Actually I haven't tried to send only json in FormData but I suspect it will fail as well. I'm not sure if ASP.NET core has native support for json objects inside formdata. I will give it a try, unfortunately tomorrow. – MistyK Jul 10 '17 at 17:12
  • its [FromForm] that you need for multipart-form sending which is content type x-www-form-urlencoded or similar and it doesn't deserialize json (which is application/json). you'll probably want to eitherdo your own deserializer for that content type or just do it manually in your action. if your json is not too complicated, you can get around by flatten it out to primitive types so it contains only IFromFile and primitive types properties. If that doesn't work for you then deserialize manually. – dee zg Jul 10 '17 at 17:16
  • @deezg thanks mate, it sounds like I'll have to flatten it. I just found this: https://stackoverflow.com/questions/43533820/form-data-not-serialized-in-asp-net-core-mvc-controller-model It answers similar question and the answer is to flatten it – MistyK Jul 10 '17 at 17:19
  • yep, for simple json structures that's good enough. i use that often. if its something more complicated with DTO validation then you'll have to go with custom model binder. – dee zg Jul 10 '17 at 17:20
  • @deezg thanks mate – MistyK Jul 10 '17 at 17:36

1 Answers1

18

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

mark.monteiro
  • 2,609
  • 2
  • 33
  • 38
  • Hi, thanks for your help, how does it work when we have an Array we want to bind? – Cedric Arnould Oct 11 '18 at 15:57
  • @CedricArnould I haven't tested with an array but the usage should be the same as with an object. Did you run into issues? – mark.monteiro Oct 11 '18 at 23:06
  • I realized that the problem comes from binding an array of abstract class, where abstract class is the real problem. I think I found a way to do, still working on it currently. Thanks for having answered – Cedric Arnould Oct 12 '18 at 16:44
  • Yeah I have the same issue as OP, and this (wonderful) solution doesn't work for an abstract class, as it gives `Model bound complex types must not be abstract or value types and must have a parameterless constructor.` So I guess I'm looking for an extra step to cast it to classes that extend from the abstract class, based on the type defined in the JSON. Oh and it's a collection of abstract classes in my case... – n2k3 Mar 04 '19 at 14:55
  • @bertuslakkis I believe you can use this solution without much modification in order to deserialize into an abstract class. What you need to do though is instruct Json.NET on how to select the concrete class to deserialize to. One option is [SerializeTypeNameHandling](https://www.newtonsoft.com/json/help/html/SerializeTypeNameHandling.htm). There are also other options that can be found on Stack Overflow like [this one](https://stackoverflow.com/a/30579193/1988326) – mark.monteiro Mar 05 '19 at 01:53