How to receive a request with CSV data in ASP.NET Core

By FoxLearn 12/26/2024 6:06:04 AM   23
In a web API, you can handle CSV data in two primary ways:
  • Receive CSV as a file in a multipart/form-data request.
  • Receive CSV as a string in a text/csv request.

In this guide, we'll walk through examples for both methods, using the CsvHelper library to parse CSV data into model objects and perform model validation.

1. Receiving a CSV File

The client can upload a CSV file in a multipart/form-data request, which you can access via Request.Form.Files or by using IFormFile parameters.

using CsvHelper;
using System.Globalization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

public class Book
{
    public string Title { get; set; }
    public string Author { get; set; }
    public int PublishedYear { get; set; }
}

[ApiController]
[Route("api/[controller]")]
public class BooksController : ControllerBase
{
    [HttpPost("upload")]
    public async Task<IActionResult> UploadBooksCsv()
    {
        var books = new List<Book>();
        IFormFile csvFile = Request.Form.Files.FirstOrDefault();

        if (csvFile == null)
        {
            return BadRequest("No CSV file uploaded.");
        }

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

                // Process each book record in the CSV
                await foreach (var book in csv.GetRecordsAsync<Book>(HttpContext.RequestAborted))
                {
                    // Perform validation on each book record as it's read
                    if (!TryValidateModel(book))
                    {
                        // Return the first validation error
                        return ValidationProblem();
                    }

                    books.Add(book);
                }
            }

            // Optional: Validate the entire list after parsing (if needed for aggregate errors)
            if (!ModelState.IsValid)
            {
                return ValidationProblem(ModelState);
            }

            // Process the list of books (e.g., save to database)
            return Ok($"Successfully uploaded {books.Count} books.");
        }
        catch (Exception ex)
        {
            // Handle CSV parsing errors or file reading exceptions
            return StatusCode(500, $"Error processing CSV file: {ex.Message}");
        }
    }
}

To test this, use Postman to send the CSV file in a POST request:

2. Receiving a CSV String (text/csv)

Another method is to send CSV data as a string in the request body with the text/csv content type. This allows you to directly read the CSV data or use a custom InputFormatter.

using CsvHelper;
using System.Globalization;
using Microsoft.AspNetCore.Mvc;
using System.Collections.Generic;
using System.IO;

public class Book
{
    public string Title { get; set; }
    public string Author { get; set; }
    public int YearPublished { get; set; }
}

[ApiController]
[Route("api/[controller]")]
public class BooksController : ControllerBase
{
    [HttpPost]
    [Consumes("text/csv")] // Ensures the request content type is text/csv
    public async Task<IActionResult> PostBooksCsv()
    {
        var books = new List<Book>();

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

                // Process each book record in the CSV
                await foreach (var book in csv.GetRecordsAsync<Book>(HttpContext.RequestAborted))
                {
                    // Perform validation on each book record as it's read
                    if (!TryValidateModel(book))
                    {
                        // Return the first validation error encountered
                        return ValidationProblem();
                    }

                    books.Add(book);
                }
            }

            // Return the result once all records are processed
            return Ok($"Successfully posted {books.Count} books.");
        }
        catch (Exception ex)
        {
            // Handle CSV parsing errors or file reading exceptions
            return StatusCode(500, $"Error processing CSV file: {ex.Message}");
        }
    }
}

3. InputFormatter to Handle text/csv with CsvHelper

An InputFormatter allows you to abstract CSV parsing from your controllers. This custom formatter handles text/csv requests by parsing the CSV data and deserializing it into model objects.

using CsvHelper;
using System.Globalization;
using Microsoft.AspNetCore.Mvc.Formatters;
using System.Collections.Generic;

public class CsvStringInputFormatter : InputFormatter
{
    public CsvStringInputFormatter()
    {
        SupportedMediaTypes.Add("text/csv");
    }

    public override async Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context)
    {
        try
        {
            // Ensure we can handle the expected list type
            var listType = context.ModelType.GenericTypeArguments[0];
            var list = (IList)Activator.CreateInstance(typeof(List<>).MakeGenericType(listType));

            // Reading the body stream and parsing the CSV data
            using var reader = new StreamReader(context.HttpContext.Request.Body);
            using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);

            // Read and add each record to the list
            await foreach (var record in csv.GetRecordsAsync(listType))
            {
                list.Add(record);
            }

            // Return the successfully parsed list
            return InputFormatterResult.Success(list);
        }
        catch (CsvHelperException ex)
        {
            // Specific exception handling for CsvHelper
            context.ModelState.TryAddModelError("Csv", $"CSV Parsing Error: {ex.Message}");
            return InputFormatterResult.Failure();
        }
        catch (Exception ex)
        {
            // General exception handling
            context.ModelState.TryAddModelError("Csv", $"An unexpected error occurred: {ex.Message}");
            return InputFormatterResult.Failure();
        }
    }

    protected override bool CanReadType(Type type)
    {
        // Ensures only lists of a generic type (List<T>) are accepted
        return type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>);
    }
}

To register the CsvStringInputFormatter in your application, make sure you add it to the list of input formatters in ConfigureServices:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers(options =>
    {
        options.InputFormatters.Add(new CsvStringInputFormatter());
    });
}

This will ensure that the formatter is used when the Content-Type is text/csv.

Now, in your action method, simply define a List<T> parameter, and model validation will be handled automatically:

[HttpPost]
[Consumes("text/csv")]
public IActionResult Post(List<Book> books)
{
    // Process the book records...
    return Ok($"Posted {books.Count} book(s)");
}

The [Consumes("text/csv")] attribute ensures that the API only accepts requests with the text/csv content type. If you remove it, the API would accept other formats as well.

4. Extension Method: CsvToListAsync()

To reduce redundancy in CSV parsing, you can create an extension method that works with both file and string-based inputs.

using CsvHelper;
using System.Globalization;
using System.IO;

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

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

            // Process records asynchronously, adding each to the list
            await foreach (var record in csv.GetRecordsAsync<T>())
            {
                list.Add(record);
            }
        }
        catch (Exception ex)
        {
            // Handle any potential exceptions (e.g., file read errors, CSV parsing errors)
            // Log the error or rethrow as needed.
            throw new InvalidOperationException("Error processing CSV data", ex);
        }

        return list;
    }
}

To use this method with the request body or file:

[HttpPost]
[Consumes("text/csv")]
public async Task<IActionResult> Post()
{
    var books = await Request.Body.CsvToListAsync<Book>();

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

    return Ok($"Posted {books.Count} book(s)");
}

For file input:

IFormFile csvFile = Request.Form.Files.First();
var books = await csvFile.OpenReadStream().CsvToListAsync<Book>();

5. Triggering Model Validation Manually

When reading the request body directly, model validation isn't triggered automatically. You need to manually invoke TryValidateModel() to validate your models:

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

When validation fails, it returns a 400 response with the validation errors:

{
    "type": "https://foxlearn.com/posts/search",
    "title": "Validation failed for one or more fields.",
    "status": 400,
    "traceId": "00-45e16d3a4f1c1234bc27ab123dabc18b-ef4bc45f798-00",
    "errors": {
        "Email": [
            "The Email field is not a valid email address."
        ],
        "Age": [
            "Age must be a number between 18 and 100."
        ]
    }
}

You can validate either individual models as you read them or perform validation on the entire list after the loop.

This article covers two methods for handling CSV data in a web API: receiving it as a file or a string. Using CsvHelper allows for easy parsing and validation of the data, while InputFormatters offer a cleaner way to handle CSV requests.