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 thePlug
interfacecall/2
handles the connection and any options that are passed. It also returns the connectionconn
. The main task here is to check if arequest_path
ends in.pdf
. If this is true if callsgenerate_pdf/2
otherwise it just passes theconn
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 ourconn
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 aMap
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 theconn
.join_cookies/1
flattens aconn.req_cookies
map and prefixes it with the the correct command line prefix:--cookie
join_options/1
flattens theoptions
passed to the plug and prefixes it with the the correct command line prefix provided by the key of theoptions
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.