Wednesday, February 6, 2013

CSV Download with ASP.NET Web API

This is something I played around with and had to cobble some code snippets together. Forcing a download is a bit different with Web API than it is with regular ASP.NET MVC. The example that I am working with is as follows: A user is presented data in a grid. Two buttons on the grid give the user options to filter data which will bring back a new data set to the grid. Roughly, what we need to do is grab the filter terms from the UI, go back to the server, query the repository, get the data we need in CSV format, force the browser to execute the file download. The trick is that in order for the browser to force a download, we need to post a form back to the controller. This example will be using the JObject. If you are not familiar with it, it is in the Newtonsoft.Json.Linq dll. It offers a really nice way to work with JSON. I like it for the flexibility where you find yourself in a one-off situation where you need a data container and dont' want to use a view model, since it will only be used once.

To start, the JavaScript. This isn't the entire script but it's the necessary part:
var values = {
   selectedFormat: null,
   selectedGenre: null
}

// pull values from UI
values.selectedGenre = $('#genre').val();
values.selectedFormat = $('#format').val();

// clear hidden form
$('#hidden').html('');

// post form back to Web API on the fly
$('<form>').attr({
   method: 'POST',
   id: 'hidden',
   action: 'http://localhost:12345/api/GridApi/ExportData'
}).appendTo('body');

$('<input>').attr({
   type: 'hidden',
   id: 'genre',
   name: 'genre',
   value: values.selectedGenre
}).appendTo('#hidden');

$('<input>').attr({
   type: 'hidden',
   if: 'format',
   name: 'format',
   value: values.selectedFormat
});

$('#hidden').submit();

One thing to point out, the first time this code runs, the hidden element ($('#hidden')) does not exist. It first comes into existence when we create the form and give it the id of hidden. However, after the page loads, if the user request another search, the line $('#hidden').html('') will clear the form.

Now for the Web API code:
public class GridApiController : ApiController
{
   [HttpPost]
   public HttpResponseMessage ExportData(JObject values)
   {
      string genre = values.GetValue("genre").ToString();
      string format = values.GetValue("format").ToString();
      SearchParams searchParams = new SearchParams() { SelectedFormat = format, SelectedGenre = genre };
      List viewModels = Search(searchParams);
      string result = ConvertListToCsv(viewModels);
      
      HttpResponseMessage message = new HttpResponseMessage(HttpStatusCode.OK);
      message.Content = new StringContent(result);
      message.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
      message.Content.Headers.ContentDisposition = new ContentDispositonHeaderValue("attachment");
      message.Content.Headers.ContentDisposition.FileName = "Test.csv");

      return message;
   }

   public List<MusicItemViewModel> Search(SearchParams values)
   {
      // code omitted, just filter logic against repository
   }

   private string ConvertListToCsv<T>(List<T> list)
   {
      if (list == null || list.Count == 0)
      {
         throw new HttpResponseException(HttpStatusCode.NotFound);
      }

      Type t = typeof(T);
      string newLine = Environment.NewLine;

      object obj = Activator.CreateInstance(t);
      PropertyInfo[] props = obj.GetType().GetProperties();
      byte[] carriageReturnBytes = System.Text.Encoding.UTF8.GetBytes("\r");

      string text;
      using (MemoryStream ms = new MemoryStream())
      using (StreamReader sr = new StreamReader(ms))
      {
         foreach (PropertyInfo pi in props)
         {
            byte[] data = System.Text.Encoding.UTF8.GetBytes(pi.Name.ToString() + ",");
            ms.Write(data, 0, data.Length);
         }

         ms.Write(carriageReturnBytes, 0, carriageReturnBytes.Length);

         foreach (T item in list)
         {
            foreach(PropertyInfo pi in props)
            {
               string write =
                  Convert.ToString(item.GetType().GetProperty(pi.Name).GetValue(item, null)).Replace(',', '') + ',';

               byte[] data = System.Text.Encoding.UTF8.GetBytes(write);
               ms.Write(data, 0, data.Length);
            }

            byte[] writeNewLine = System.Text.Encoding.UTF8.GetBytes(Environment.NewLine);
            ms.Write(writeNewLine, 0, writeNewLine.Length);
         }

         ms.Position = 0;
         text = sr.ReadToEnd();
         return text;
      }
   }
}

A short note. If you are separating your scripts for a modular purpose, separation of concerns, etc, here is an additional approach. If you have a client side data service to call back to the server, it's only a minor change....
// the original script abbreviated
// post form back to Web API on the fly
$('<form>').attr({
   method: 'POST',
   id: 'hidden',
   action: 'http://localhost:12345/api/GridApi/ExportData'
}).appendTo('body');

$('<input>').attr({
   type: 'hidden',
   id: 'genre',
   name: 'genre',
   value: values.selectedGenre
}).appendTo('#hidden');

$('<input>').attr({
   type: 'hidden',
   if: 'format',
   name: 'format',
   value: values.selectedFormat
});

// commented out
//$('#hidden').submit();

// assign hidden form to variable
var hiddenForm = $('#hidden');

// call data service
dataService.ExportGridData(hiddenForm);


// dataService.js
var dataService = function () {

   var exportGridData = function (hiddenForm) {
      // wrap hidden form in jQuery so we can call submit
      $(hiddenForm).submit();
   };

   return {
      ExportGridData: exportGridData
   }
}

The form we created on the fly contains the URL to call on the controller so everything will work as it should.