Using WKHTMLTOPDF in Phoenix as a Plug

One really nice feature in the Rails world is the use of middleware to intercept requests and transform the output. This also exists in Elixir / Phoenix with the use of Plug.

Anybody familiar with the PDFKit gem in Rails may have used the middleware that automatically generates a PDF of a website if the route ends in .pdf. This was pleasantly simple in Elixir as the below code shows:

WARNING: This method may leak your cookie data to a system log. If you are not confident that your environment is secure, I would recommend looking for another solution.

defmodule Sample.PdfPlug do
  import Plug.Conn
  import Plug.Upload

  @wkhtmltopdf_path System.find_executable("wkhtmltopdf")

  def init(opts) do
    opts
  end

  def call(conn, opts) do
    case Regex.match?(~r/.*(pdf)$/, conn.request_path) do
      true -> generate_pdf(conn, opts)
      false -> conn
    end
  end

  defp generate_pdf(conn, opts) do
    url = "#{conn.scheme}://#{conn.host}:#{conn.port}#{conn.request_path}"
    {:ok, file} = random_file("test")
    conn = fetch_cookies(conn)
    args = ["-q"] ++ join_cookies(conn.req_cookies) ++ join_options(opts) ++ [String.replace(url, ".pdf", ""), file]
    System.cmd(@wkhtmltopdf_path, args)
    conn
      |> send_file(200, file)
      |> halt
  end

  defp join_cookies(cookies) when cookies == %{}, do: []
  defp join_cookies(cookies) do
    cookies
      |> Enum.map(fn{k,v} -> ["--cookie", "#{k}", "#{URI.encode(v)}"] end)
      |> List.flatten
  end

  defp join_options(options) do
    options
      |> Enum.map(fn{k,v} -> ["--#{k}", "#{v}"] end)
      |> List.flatten
  end
end

Let us break down each function:

  • init/1 takes the options passed to the plug and is required by the Plug interface

  • call/2 handles the connection and any options that are passed. It also returns the connection conn. The main task here is to check if a request_path ends in .pdf. If this is true if calls generate_pdf/2 otherwise it just passes the conn to the next plug in the pipeline.

  • generate_pdf/2 takes the connection and handles WKHTMLTOPDF specific options. First it reconstructions the entire URL that is to be turned into a PDF. Next it create a random temporary file that will be used to store the created PDF before it gets sent to the client. Following that we need to make sure our conn contains all the cookie information that is part of the request. This will allow us to use any type of user authentication that is required. conn.req_cookies is a Map that needs to be flattened so it can be passed as part a command line parameter. The same applies to the WKHTMLTOPDF options passed to the plug. As the last part of our command line we include the URL to get and the file to put the resulting PDF into. Lastly we execute the command, return the content of the file and halt the conn.

  • join_cookies/1 flattens a conn.req_cookies map and prefixes it with the the correct command line prefix: --cookie

  • join_options/1 flattens the options passed to the plug and prefixes it with the the correct command line prefix provided by the key of the options map. Ex: --orientation 'landscape'

The last thing that needs to be done is a specific .pdf ending route defined in router.ex and add in a plug:

pipeline :browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :fetch_flash
  plug :protect_from_forgery
  plug :put_secure_browser_headers
  plug Sample.PdfPlug, orientation: 'landscape'
end

scope "/", Sample do
  pipe_through :browser
  ...
  get "/reports.pdf", ReportController, :index, as: "print"
end

Why is this particularly awesome

If you used PDFKit in the WEBrick or Unicorn days of Rails you probably sat down and waited … and waited … and waited until your request to generate the PDF timed out. That was because WEBrick and Unicorn (if not properly configured), where single threaded. That means because the client was requesting the PDF, WKHTMLTOPDF was blocked from requesting the page to render to PDF. With Elixir/Phoenix the default web server cowboy is multithreaded out of box, so this was not worth even thinking about.