ASP.NET Core – Receive a request with CSV data

There are two ways to receive CSV data in a web API:

  • Receive CSV as a file in a multipart/form-data request.
  • Receive CSV as a string in a text/csv request.

In this article, I’ll show examples of both of these approaches. I’ll be using the CsvHelper library to parse CSV data into model objects and then do model validation.

Note: To use CsvHelper, install the CsvHelper package (Install-Package CsvHelper). Or use whichever parser you prefer. I don’t recommend parsing manually unless you are dealing with very simple data.

Receiving a CSV file

The client can upload one or more CSV files in a multipart/form-data request. You can access these files through Request.Form.Files (or by adding IFormFile parameters).

Here’s an example of receiving a CSV file, parsing it with CsvHelper, and doing validation on the models:

using CsvHelper;
using System.Globalization;

[HttpPost()]
public async Task<IActionResult> Post()
{
	var movies = new List<Movie>();
	IFormFile csvFile = Request.Form.Files.First();

	using (var reader = new StreamReader(csvFile.OpenReadStream()))
	{
		using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);

		await foreach (var movie in csv.GetRecordsAsync<Movie>(HttpContext.RequestAborted))
		{
			//Fail fast - return the first error encountered
			if (!TryValidateModel(movie))
				return ValidationProblem();

			movies.Add(movie);
		}
	}

	//note: validate the whole list here if you want ALL validation errors

	//process movies....
	return Ok($"Posted movie CSV file. Count={movies.Count}.");
}
Code language: C# (cs)

To see this work, use Postman to send the CSV file in a request:

POST /Movies

Content-Type: multipart/form-data; boundary=...

Body:
<the CSV file data>Code language: plaintext (plaintext)

The CSV file is parsed and the models are validated and it returns the following response:

Status code: 200 (OK)

Body:
Posted movie CSV file. Count=5000Code language: plaintext (plaintext)

Receiving a CSV string (text/csv)

The client can send a CSV string in a text/csv request. You can read the CSV string from the request body in the action method directly or you can read it in a custom InputFormatter.

Here’s an example of reading the CSV string from the body with CsvHelper and looping through the results. It validates each model and then adds them to a list if pass validation:

using CsvHelper;
using System.Globalization;

[HttpPost()]
[Consumes("text/csv")] //optional - use this to require this content-type
public async Task<IActionResult> Post()
{
	var movies = new List<Movie>();

	using (var reader = new StreamReader(Request.Body))
	{
		using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);

		await foreach (var movie in csv.GetRecordsAsync<Movie>(HttpContext.RequestAborted))
		{
			//Fail fast - return the first validation error encountered
			if (!TryValidateModel(movie))
				return ValidationProblem();

			movies.Add(movie);
		}
	}

	//note: validate the whole list here if you want ALL validation errors

	//process movies....

	return Ok($"Posted {movies.Count} movie(s)");
}
Code language: C# (cs)

Here’s an example of sending a CSV string in a text/csv request:

POST /Movies

Content-Type: text/csv

Body:
Title,Year,Director
Inception,2010,Christopher Nolan
Pulp Fiction,1994,Quentin Tarantino
Jurassic Park,1993,Steven SpielbergCode language: plaintext (plaintext)

The CSV string is parsed, the models are validated, and it returns the following response:

Status code: 200 (OK)

Body:
Posted 3 movie(s)Code language: plaintext (plaintext)

InputFormatter to handle text/csv with CsvHelper

Input formatters are used to deserialize the request body into model parameters. This is an alternative to reading the request body directly in the controllers.

Here’s an example of implementing a custom InputFormatter to handle parsing a CSV string in a text/csv request with CsvHelper. This is registered to handle parsing requests with Content-Type text/csv for action methods with List<T> parameters:

using CsvHelper;
using System.Globalization;
using Microsoft.AspNetCore.Mvc.Formatters;
using System.Collections; //add this to get the non-generic IList


public class CsvStringInputFormatter : InputFormatter
{
	public CsvStringInputFormatter()
	{
		SupportedMediaTypes.Add("text/csv");
	}
	public override async Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context)
	{
		try
		{
			using (var reader = new StreamReader(context.HttpContext.Request.Body))
			{
				using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
				var list = Activator.CreateInstance(context.ModelType) as IList; 
				var modelTypeInList = context.ModelType.GenericTypeArguments[0];

				await foreach (var record in csv.GetRecordsAsync(modelTypeInList))
				{
					list.Add(record);
				}

				return InputFormatterResult.Success(list);
			}
		}
		catch (Exception ex)
		{
			context.ModelState.TryAddModelError("Csv", ex.Message);
			return InputFormatterResult.Failure();
		}
	}
	protected override bool CanReadType(Type type)
	{
		return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>);
	}
}
Code language: C# (cs)

Note: Since this is made to handle all List<T> parameters, and it doesn’t know the List’s generic argument (T) until runtime, it has to use reflection and Activator.CreateInstance() to create the strongly typed list.

Now register CsvStringInputFormatter by adding it to InputFormatters in the initialization code:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers(o => o.InputFormatters.Add(new CsvStringInputFormatter()));

//rest of init code...
Code language: C# (cs)

In your action method, you can add a List<T> parameter and then process the records:

[HttpPost]
[Consumes("text/csv")] //optional - use to require this content-type
public IActionResult Post(List<Movie> movies)
{
	//process movie records...

	return Ok($"Posted {movies.Count} movie(s)");
}
Code language: C# (cs)

Notice that model validation is automatically performed by the framework and you don’t need to manually do it yourself in the action method.

Extension method – CsvToListAsync()

Notice that the CSV parsing code is practically the same in both scenarios – reading from a body stream vs reading from a file stream. This means it can be extracted into a generic extension method:

using CsvHelper;
using System.Globalization;

public static class RequestExtensions
{
	public static async Task<List<T>> CsvToListAsync<T>(this Stream stream)
	{
		var list = new List<T>();

		using (var reader = new StreamReader(stream))
		{
			using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);

			await foreach (var record in csv.GetRecordsAsync<T>())
			{
				list.Add(record);
			}
		}

		return list;
	}
}
Code language: C# (cs)

This allows you to keep the controllers nice and clean and makes the parsing code reusable.

Here’s an example of calling this on the body stream:

[HttpPost]
[Consumes("text/csv")] //optional - use this to require this content-type
public async Task<IActionResult> Post()
{
	var movies = await Request.Body.CsvToListAsync<Movie>();

	if (!TryValidateModel(movies))
		return ValidationProblem();

	//process movie records...

	return Ok($"Posted {movies.Count} movie(s)");
}
Code language: C# (cs)

And here’s how to call this on a CSV file stream:

IFormFile csvFile = Request.Form.Files.First();

var movies = await csvFile.OpenReadStream().CsvToListAsync<Movie>();
Code language: C# (cs)

Triggering model validation manually

When you read the request body yourself and don’t have model parameters, the framework can’t do model validation for you automatically. Instead, you can trigger model validation manually in the controller by calling TryValidateModel() (as shown throughout this article):

if (!TryValidateModel(movie))
	return ValidationProblem();
Code language: C# (cs)

When validation fails, this returns the model validation errors (from ModelState) and the framework produces a problem details error response (400 – Bad Request) that looks like this:

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-82685bab692df6760184b47a75fd6b54-ebb87c382c05688d-00",
    "errors": {
        "Year": [
            "The field Year must be between 1900 and 2022."
        ]
    }
}
Code language: JSON / JSON with Comments (json)

You can call TryValidateModel() on individual model objects in the reading loop, or you can call it after the loop on the list of model objects. It depends if you’d rather fail fast and minimize unnecessary work, or if you’d rather get all of the validation errors returned at once.

Leave a Comment