typescriptasp.net-coreaxiosmultipartform-datamodel-binding

How to bind an axios request that contains an array of files to an ASP.NET Core model that contains a List<IFormFile> variable


I have started to take advantage of Axios header option multipart/form-data so I can go utilize Typescript objects for my requests to bind to my ASP.NET Core models, however List<IFormFile> properties do not seem to bind and stay as null, while my other properties bind fine, including a single IFormFile property.

My Typescript interface is as follows:

export interface AddReview {
    primaryDoc: File; // Required, PDF
    supportingDocuments: File[] | null;
    stateId: number; // Required
}

And my axios request is:

const addReview = async (submissionData :  SDP.AddReview) : Promise<any | null> => {

const data = api.post(`/sdp/Review/`, submissionData, {
  headers: {
    'Content-Type': 'multipart/form-data'
  }
})
.then(response => { 
  const apiResponse : API.Response = response.data
  const review : any = apiResponse.data
  return review
}).catch(error => {
  if(error.response.status == 400) {
    router.push({ name: 'Error'})
  }
  return null
})
return data
}

Then this should bind to my AddReviewDTO model class in C#:

public class AddReviewDTO
{
    [Required]
    public required IFormFile PrimaryDoc { get; set; }

    public List<IFormFile>? SupportingDocuments { get; set; }

    [Required]
    public required int StateId { get; set; }
}

The controller action that is trying to achieve this action is as follows:

public async Task<IActionResult> AddReviewAsync([FromForm] AddReviewDTO addDTO)
{
    string json = JsonSerializer.Serialize(addReviewDTO);
}

I know there are instances where custom model binders are required in ASP.NET Core but this seems so basic so I am hoping this can be done without that sort of solution as I want to reuse this approach on many scenarios with different models. In addition, I do not want to go back to the approaching of looping through my entire object to add to FormData manually as this was tedious.

Here is a similar question but no answer without switching to complete FormData approach which I use to use and want to get away from:

.Net 7 API won't accept List<IFormFile>

My only solution so far is this utility function I had ChatGPT Create:

const objectToFormData = (
  obj: Record<string, any>,
  form: FormData = new FormData(),
  namespace: string = ''
): FormData => {
  Object.keys(obj).forEach((key) => {
    const value = obj[key];
    if (value === undefined || value === null) return;

    const formKey = namespace ? `${namespace}.${key}` : key;

    if (value instanceof Date) {
      form.append(formKey, value.toISOString());
    } else if (value instanceof File || value instanceof Blob) {
      form.append(formKey, value, value.name);
    } else if (Array.isArray(value)) {
      value.forEach((element) => {
        if (element instanceof File || element instanceof Blob) {
          // ✅ Multiple files should use the same key (no index)
          form.append(formKey, element, element.name);
        } else if (typeof element === 'object' && element !== null) {
          // Use dot notation or any custom naming here
          objectToFormData(element, form, formKey);
        } else {
          form.append(formKey, String(element));
        }
      });
    } else if (typeof value === 'object') {
      objectToFormData(value, form, formKey);
    } else {
      form.append(formKey, String(value));
    }
  });

  return form;
}

This will convert the object to FormData.


Solution

  • ASP.NET expects repeated fields in multipart/form-data payloads to have no [] or [n] suffixes whereas Axios by default opts for the PHP-style [] suffix.

    You can disable the index suffixes by configuring the formSerializer option, best applied at your instance creation...

    const api = axios.create({
      // ...
      formSerializer: {
        indexes: null,
      }
    })
    

    or inline with your request

    const data = api.post(`/sdp/Review/`, submissionData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      },
      formSerializer: {
        index: null,
      },
    });
    

    See Automatic serialization to FormData

    • indexes: null|false|true = false - controls how indexes will be added to unwrapped keys of flat array-like objects.
      • null - don't add brackets (arr: 1, arr: 2, arr: 3)
      • false(default) - add empty brackets (arr[]: 1, arr[]: 2, arr[]: 3)
      • true - add brackets with indexes (arr[0]: 1, arr[1]: 2, arr[2]: 3)