Uploading files in ASPNET Core

asp.net webapi webapp sql

Uploading files in ASPNET Core is largely the same as standard full framework MVC, but now we can stream large files. We will go over both methods of uploading a file in ASP.NET Core.

The source code of this post is on GitHub. I wrote another post about Upload/Download Files using HttpClient that maybe can interest you.

Model Binding IFormFile (Small Files)

So, when uploading a file via this method, the important thing to note is that your files are uploaded in their entirety before execution hits your controller action. What this means is that the disk on your server holds a temporary file while you decide where to push it. With these small files this is fine, larger files you run into issues of scale. If you have many users all uploading large files you are liable to run out of ram (where the file is stored before moving it to disk), or disk space itself.

For your HTML, it should look something like this:

<form method="post" enctype="multipart/form-data" action="/Upload">
    <div>
        <p>Upload one or more files using this form:</p>
        <input type="file" name="files" />
    </div>
    <div>
         <input type="submit" value="Upload" />
    </div>
</form>

The biggest thing to note is that the encoding type is set to multipart/form-data, if this is not set then you will go crazy trying to hunt down why your file is showing up in your controller.

Your controller action is actually very simple. It will look something like:

[HttpPost]
public IActionResult Index(List<IFormFile> files)
{
	//Do something with the files here. 
	return Ok();
}

Note that the name of the parameter “files” should match the name on the input in HTML. Other than that, you are there and done. There is nothing more than you need to do.

Streaming Files (Large Files)

Now, for large files, instead of buffering the file in its entirety, you can stream the file upload. This introduces challenges as you can no longer use the built-in model binding of ASP.NET Core. Various tutorials out there show you how to get things working with massive pieces of code, but I’ll give you a helper class that should alleviate most of the work. Most of this work is taken from Microsoft’s tutorial on file uploads here. Unfortunately, it’s a bit all over the place with helper classes that you need to dig around the web for.

First, take this helper class and stick it in your project. This code is taken from a Microsoft project here.

MultipartRequestHelper code

public static class MultipartRequestHelper
{
    // Content-Type: multipart/form-data; boundary="----WebKitFormBoundarymx2fSWqWSd0OxQqq"
    // The spec says 70 characters is a reasonable limit.
    public static string GetBoundary(MediaTypeHeaderValue contentType, int lengthLimit)
    {
        // .NET Core <2.0
        //var boundary = Microsoft.Net.Http.Headers.HeaderUtilities.RemoveQuotes(contentType.Boundary);
        //.NET Core 2.0
        var boundary = Microsoft.Net.Http.Headers.HeaderUtilities.RemoveQuotes(contentType.Boundary).Value; 
        if (string.IsNullOrWhiteSpace(boundary))
        {
            throw new InvalidDataException("Missing content-type boundary.");
        }

        if (boundary.Length > lengthLimit)
        {
            throw new InvalidDataException(
                $"Multipart boundary length limit {lengthLimit} exceeded.");
        }

        return boundary;
    }

    public static bool IsMultipartContentType(string contentType)
    {
        return !string.IsNullOrEmpty(contentType)
                && contentType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0;
    }

    public static bool HasFormDataContentDisposition(ContentDispositionHeaderValue contentDisposition)
    {
        // Content-Disposition: form-data; name="key";
        return contentDisposition != null
                && contentDisposition.DispositionType.Equals("form-data")
                && string.IsNullOrEmpty(contentDisposition.FileName.Value) // For .NET Core <2.0 remove ".Value"
                && string.IsNullOrEmpty(contentDisposition.FileNameStar.Value); // For .NET Core <2.0 remove ".Value"
        }

    public static bool HasFileContentDisposition(ContentDispositionHeaderValue contentDisposition)
    {
        // Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg"
        return contentDisposition != null
                && contentDisposition.DispositionType.Equals("form-data")
                && (!string.IsNullOrEmpty(contentDisposition.FileName.Value) // For .NET Core <2.0 remove ".Value"
                    || !string.IsNullOrEmpty(contentDisposition.FileNameStar.Value)); // For .NET Core <2.0 remove ".Value"
    }
}

Implement FileStreamingHelper

Next, you can need this extension class which again is taken from Microsoft code but moved into a helper so that it can be reused and it allows your controllers to be slightly cleaner. It takes an input stream which is where your file will be written to. This is kept as a basic stream because the stream can really come from anywhere. It could be a file on the local server, or a stream to Azure or other clouds

public static class FileStreamingHelper
{
	private static readonly FormOptions _defaultFormOptions = new FormOptions();

	public static async Task<FormValueProvider> StreamFile(this HttpRequest request, Stream targetStream)
	{
		if (!MultipartRequestHelper.IsMultipartContentType(request.ContentType))
		{
			throw new Exception($"Expected a multipart request, but got {request.ContentType}");
		}

		// Used to accumulate all the form url encoded key value pairs in the 
		// request.
		var formAccumulator = new KeyValueAccumulator();
		string targetFilePath = null;

		var boundary = MultipartRequestHelper.GetBoundary(
			MediaTypeHeaderValue.Parse(request.ContentType),
			_defaultFormOptions.MultipartBoundaryLengthLimit);
		var reader = new MultipartReader(boundary, request.Body);

		var section = await reader.ReadNextSectionAsync();
		while (section != null)
		{
			ContentDispositionHeaderValue contentDisposition;
			var hasContentDispositionHeader = 
                 ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out contentDisposition);

			if (hasContentDispositionHeader)
			{
				if (MultipartRequestHelper.HasFileContentDisposition(contentDisposition))
				{
					await section.Body.CopyToAsync(targetStream);
				}
				else if (MultipartRequestHelper.HasFormDataContentDisposition(contentDisposition))
				{
					// Content-Disposition: form-data; name="key"
					//
					// value

					// Do not limit the key name length here because the 
					// multipart headers length limit is already in effect.
					var key = HeaderUtilities.RemoveQuotes(contentDisposition.Name);
					var encoding = GetEncoding(section);
					using (var streamReader = new StreamReader(
						section.Body,
						encoding,
						detectEncodingFromByteOrderMarks: true,
						bufferSize: 1024,
						leaveOpen: true))
					{
						// The value length limit is enforced by MultipartBodyLengthLimit
						var value = await streamReader.ReadToEndAsync();
						if (String.Equals(value, "undefined", StringComparison.OrdinalIgnoreCase))
						{
							value = String.Empty;
						}
						formAccumulator.Append(key.Value, value); // For .NET Core <2.0 remove ".Value" from key

						if (formAccumulator.ValueCount > _defaultFormOptions.ValueCountLimit)
						{
							throw new InvalidDataException($"Form key count limit {_defaultFormOptions.ValueCountLimit} exceeded.");
						}
					}
				}
			}

			// Drains any remaining section body that has not been consumed and
			// reads the headers for the next section.
			section = await reader.ReadNextSectionAsync();
		}

		// Bind form data to a model
		var formValueProvider = new FormValueProvider(
			BindingSource.Form,
			new FormCollection(formAccumulator.GetResults()),
			CultureInfo.CurrentCulture);

		return formValueProvider;
	}

	private static Encoding GetEncoding(MultipartSection section)
	{
		MediaTypeHeaderValue mediaType;
		var hasMediaTypeHeader = MediaTypeHeaderValue.TryParse(section.ContentType, out mediaType);
		// UTF-7 is insecure and should not be honored. UTF-8 will succeed in 
		// most cases.
		if (!hasMediaTypeHeader || Encoding.UTF7.Equals(mediaType.Encoding))
		{
			return Encoding.UTF8;
		}
		return mediaType.Encoding;
	}
}

Add Attribute

Now, you actually need to create a custom action attribute that completely disables form binding. This is important otherwise C# will still try and load the contents of the request regardless. The attribute looks like:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter
{
    public void OnResourceExecuting(ResourceExecutingContext context)
    {
        var formValueProviderFactory = context.ValueProviderFactories
            .OfType<FormValueProviderFactory>()
            .FirstOrDefault();
        if (formValueProviderFactory != null)
        {
            context.ValueProviderFactories.Remove(formValueProviderFactory);
        }

        var jqueryFormValueProviderFactory = context.ValueProviderFactories
            .OfType<JQueryFormValueProviderFactory>()
            .FirstOrDefault();
        if (jqueryFormValueProviderFactory != null)
        {
            context.ValueProviderFactories.Remove(jqueryFormValueProviderFactory);
        }
    }

    public void OnResourceExecuted(ResourceExecutedContext context)
    {
    }
}

Next, your controller should look similar to the following. We create a stream and pass it into the StreamFile extension method. The output is our FormValueProvider which we use to bind out model manually after the file streaming. Remember to put your custom attribute on the action to force the request to not bind.

[HttpPost]
[DisableFormValueModelBinding]
public async Task<IActionResult> Index()
{
	FormValueProvider formModel;
	using (var stream = System.IO.File.Create("c:\\temp\\myfile.temp"))
	{
		formModel = await Request.StreamFile(stream);
	}

	var viewModel = new MyViewModel();

	var bindingSuccessful = await TryUpdateModelAsync(viewModel, prefix: "",
	   valueProvider: formModel);

	if (!bindingSuccessful)
	{
		if (!ModelState.IsValid)
		{
			return BadRequest(ModelState);
		}
	}

	return Ok(viewModel);
}

In my particular example I have created a stream that writes to a temp file, but obviously you can do anything you want with it. The model I am using is the following:

public class MyViewModel
{
	public string Username { get; set; }
}

Again, nothing special. It’s just showing you how to bind a view model even when you are streaming files.

My HTML form is pretty standard:

<form method="post" enctype="multipart/form-data" action="/Upload">
    <div>
        <p>Upload one or more files using this form:</p>
        <input type="file" name="files" multiple />
    </div>
    <div>
        <p>Your Username</p>
        <input type="text" name="username" />
    </div>
    <div>
         <input type="submit" value="Upload" />
    </div>
</form>

And that’s all there is to it. Now when I upload a file, it is streamed straight to my target stream which could be an upload stream to AWS/Azure or any other cloud provider, and I still managed to get my view model out too. The biggest downside is of course that the view model details are not available until the file has been streamed.

What this means is that if there is something in the view model that would normally determine where the file goes, or the name etc, this is not available to you without a bit of rejigging.

Run the app

So, if you run the web application you have the following screenshot. The source code is available on GitHub.

The application with .NET Core 2.x or 3.x shows few options
The application with .NET Core 2.x or 3.x shows few options

Upload large files with .NET Core 5.x or above

Now, if you are using .NET Core 5.x or above, the code is quite simplified. This is the code for the UploadLargeFile

[HttpPost]
[Route(nameof(UploadLargeFile))]
public async Task<IActionResult> UploadLargeFile()
{
    var request = HttpContext.Request;

    // validation of Content-Type
    // 1. first, it must be a form-data request
    // 2. a boundary should be found in the Content-Type
    if (!request.HasFormContentType ||
        !MediaTypeHeaderValue.TryParse(request.ContentType, out var mediaTypeHeader) ||
        string.IsNullOrEmpty(mediaTypeHeader.Boundary.Value))
    {
        return new UnsupportedMediaTypeResult();
    }

    var reader = new MultipartReader(mediaTypeHeader.Boundary.Value, request.Body);
    var section = await reader.ReadNextSectionAsync();

    // This sample try to get the first file from request and save it
    // Make changes according to your needs in actual use
    while (section != null)
    {
        var hasContentDispositionHeader = ContentDispositionHeaderValue.TryParse(section.ContentDisposition,
            out var contentDisposition);

        if (hasContentDispositionHeader && contentDisposition.DispositionType.Equals("form-data") &&
            !string.IsNullOrEmpty(contentDisposition.FileName.Value))
        {
            // Don't trust any file name, file extension, and file data from the request unless you trust them completely
            // Otherwise, it is very likely to cause problems such as virus uploading, disk filling, etc
            // In short, it is necessary to restrict and verify the upload
            // Here, we just use the temporary folder and a random file name

            // Get the temporary folder, and combine a random file name with it
            var fileName = Path.GetRandomFileName();
            var saveToPath = Path.Combine(Path.GetTempPath(), fileName);

            using (var targetStream = System.IO.File.Create(saveToPath))
            {
                await section.Body.CopyToAsync(targetStream);
            }

            return Ok();
        }

        section = await reader.ReadNextSectionAsync();
    }

    // If the code runs to this location, it means that no files have been saved
    return BadRequest("No files data in the request.");
}

Test the API with Postman

Now, I want to test the API but I don’t have Swagger. So, I have to use Postman or a similar tool to the test the API. Then, in the Body section, add a new key with a name (the name doesn’t matter), select from the dropdown list the option File and then select from your computer a valid file to upload.

Upload a big file with Postman - Uploading files in ASPNET Core
Upload a big file with Postman

Uploading file with Swagger

Now, there were some obsolete methods which were no longer supported in new framework so they had to be rewritten. One among these was Swagger operation filter responsible for proper file upload representation in Swagger UI. To describe the problem I used a simple sample web api with one controller and with only one controller method only for uploading the file:

[ApiController]
[Route("[controller]")]
public class FileUploadController : ControllerBase
{

    [HttpPost]
    public async Task<IActionResult> UploadDocument(
        [FromHeader] String documentType,
        [FromForm] IFormFile file
        )
    {
        // TODO: handle file upload
        return await Task.FromResult(Ok());
    }
}

This is how you would typically add file as a parameter for upload to an API endpoint in ASP.NET Core controller method. However, when you run this from your IDE you will get the following UI representation in Swagger UI.

Swagger can't show a File input
Swagger can’t show a File input

As you can see, file upload is not properly represented in the Swagger UI which makes testing of this endpoint pretty much impossible directly from the Swagger UI directly and in order to test it you would have to use Postman or CURL.

Ideally, we should have a file upload control here so we can pick the file from the local machine and test the endpoint. This is where custom Swagger operation filter comes into play. We’ll intercept SwaggerGen OAS generation process via our custom IOperationFilter implementation to describe this endpoint properly and render out the proper UI.

Swagger file upload operation filter

So, if you look at this endpoint and potentially any endpoint you may have for uploading the files in your Web API, you will notice that at least one of the parameters, specifically the one that carries the file content is of type IFormFile. We can use this to identify the file upload methods and alter them.

After we apply this condition in our filter, we just need to alter the operation parameter schema properties with the new ones that indicate the binary content.

public class SwaggerFileOperationFilter : IOperationFilter
{
    public void Apply(OpenApiOperation operation, OperationFilterContext context)
    {
        var fileUploadMime = "multipart/form-data";
        if (operation.RequestBody == null || 
            !operation.RequestBody.Content.Any(x => 
                x.Key.Equals(fileUploadMime, StringComparison.InvariantCultureIgnoreCase)))
            return;

        var fileParams = context.MethodInfo.GetParameters().Where(p => p.ParameterType == typeof(IFormFile));
        operation.RequestBody.Content[fileUploadMime].Schema.Properties =
            fileParams.ToDictionary(k => k.Name, v => new OpenApiSchema()
            {
                Type = "string",
                Format = "binary"
            });
    }
}

The only thing left is to plug in this operation class to our Web API DI container registration in Startup.cs

public void ConfigureServices(IServiceCollection services)  
{  
  
    services.AddControllers();  
    services.AddSwaggerGen(c =>  
    {  
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "Sample.FileUpload.Api", Version = "v1" });  
        c.OperationFilter<SwaggerFileOperationFilter>();  
    });  
}  

Now, once we start the service and access the Swagger UI, we’ll get the proper UI and you can select the file from local machine and test the endpoint directly from Swagger UI.

Upload file from Swagger - Uploading files in ASPNET Core
Upload file from Swagger

Wrap up

In conclusion, we saw the way for uploading files in ASPNET Core using .NET Core version 2 or 3 or the new version 5 or 6.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.