Exporting CSVs Using Phoenix

Phoenix is the de facto web framework for Elixir, and it’s quite common to need to export CSVs from a web app. Let’s have a look at how this works in Phoenix.

We’ll be using Phoenix 1.2, with the CSV package to get everything going.

Adding the CSV package

First, we need to install CSV – the package is good for both encoding and decoding CSV files so it will handle all the heavy lifting for us. Add the following to mix.exs:

defp deps do
  [
    # The rest of your dependencies will be here
    {:csv, "~> 1.4.0"}
  ]
end

Once this is done, run mix deps.get and we’ll be able to use CSV in our app.

Working with CSV

Before we create our endpoint, let’s have a look how CSV works. Here, we’ll be working with a list of lists, as this translates directly to the type of data that CSV.encode/2 is expecting. A quick example of how this works:

raw_csv_content = [["a", "list"],["of", "lists"]]
  |> CSV.encode
  |> Enum.to_list
  |> to_string

raw_csv_content # => "a,list\r\nof,lists\r\n"

Let’s break down what’s happening here.

First, we take a list of lists and pass it to CSV.encode/2.

CSV.encode/2 expects a stream of data in a tabular format, and encodes it to RFC 4180 compliant CSV lines. If you don’t know what RFC 4180 is, don’t worry, I didn’t either, but I did some googling and found the Common Format and MIME Type for Comma-Separated Values (CSV) Files specification. Great! Since CSV returns a stream, we need to turn it back into a list. Streams are great for working with large datasets, but are more complex and worth an entire post in themselves.

The list we get back from Enum.to_list/1 looks like this:

["a,list\r\n", "of,lists\r\n"]

Each of the items in it is a CSV row, so to make the entire CSV, we could write it to a file, or in our case, we’ll send it along as the response from our endpoint.

Let’s create the endpoint now.

Creating our CSV controller and endpoint

The first thing that we need to do is add an endpoint to the router. For this example, we’ll add our own controller called CsvController with the action export.

First, let’s define the route:

  scope "/", ExampleApp do
    pipe_through :browser # Use the default browser stack

    get "/csv", CsvController, :export
  end

Now, when we make a get request to /csv it will hit the export action in our CsvController.

At this point, your app won’t compile, because we haven’t made the controller. We’ll do that next.

Create a new file at web/controllers/csv_controller.ex that looks like this:

defmodule ExampleApp.CsvController do
  use ExampleApp.Web, :controller

  def export(conn, _params) do
    conn
    |> put_resp_content_type("text/csv")
    |> put_resp_header("content-disposition", "attachment; filename=\"A Real CSV.csv\"")
    |> send_resp(200, csv_content)
  end

  defp csv_content do
    csv_content = [['a', 'list'],['of', 'lists']]
    |> CSV.encode
    |> Enum.to_list
    |> to_string
  end
end

This is the whole thing – if you stop now, it’ll work. Visit /csv in your browser and you’ll find yourself downloading our very important CSV file. 😉

Let’s talk about what’s happening. If you look at the private function csv_content, you’ll notice that it’s the same thing we were working with in the Working with CSV section. It turns the list into a string of CSV rows.

The stuff we haven’t seen yet is happening in the export function. Because we’re not sending back a normal HTML response, we use put_resp_content_type/2 to set the Content-Type header to the correct MIME type – text/csv. Then, we use put_resp_header/3 to set the Content-Disposition header to specify that it is an attachment, with the filename A Real CSV.csv.

Finally, we send the response, with a 200 status (or success), and the stringified contents of the CSV itself.

In this endpoint, we’re using two functions from Plug directly. You can read more in the docs for each – put_resp_content_type docs, and put_resp_header docs.

Regarding put_resp_header

You may have noticed that we’re passing a lower case "content-disposition" header name to put_resp_header. This the recommended approach with Plug, as the docs say:

It is recommended for header keys to be in lower-case, to avoid sending duplicate keys in a request. As a convenience, this is validated during testing which raises a Plug.Conn.InvalidHeaderError if the header key is not lowercase.

Using Plug like this illustrates how easy it is to dig down to the lower level functions that allow you to do extremely powerful things without much effort.

Linking in a template

Last but not least, we’ll want to link to the CSV exporting endpoint. Pop this code into a template, and now all your end users will be able to download CSVs!

<%= link "Download CSV", to: csv_path(@conn, :export) %>