Jan 27, 2023
Table of contents:
I recently ran into a problem embedding XML templates in a Phoenix 1.7 application. It took me a while to figure out what was going wrong and so I thought I’d write up this blog post to try and save other people time in the future.
Update: José Valim has updated Phoenix.Template
to include an implementation of the embed_templates/2
macro that means these changes are no longer required!
Phoenix 1.7 introduced a new unified rendering model with the deprecation of Phoenix.View
in favour of Phoenix.Template
, which uses function components as the basis for all rendering in the framework. Now, instead of having a AppWeb.UserView
module, you would have modules such as AppWeb.UserHTML
, or AppWeb.UserJSON
depending on the format of the request.
The AppWeb.UserHTML
module will typically looks something like this:
defmodule AppWeb.UserHTML do
use AppWeb, :html
embed_templates "user_html/*"
end
The embed_templates
macro in the example above accepts a pattern that points to a directory of .heex
templates.
The AppWeb.UserJSON
module might looks something like this:
defmodule AppWeb.UserJSON do
alias AppWeb.User
@doc """
Renders a list of users
"""
def index(%{users: users}) do
%{data: for(user <- users, do: data(user))}
end
@doc """
Renders a single users
"""
def show(%{user: user}) do
%{data: data(user)}
end
defp data(%User{} = user) do
%{id: user.id, name: user.name}
end
end
When Phoenix is looking to render the response, it will first attempt to call a function on the module with the same name as the request. If the module does not have a function with that name, it will instead fallback to calling render/2
with the name of the template and the assigns.
For example, say we had a route that was /users
, which pointed to the UserController
and the index
function. The request has a format of HTML, and so Phoenix would first attempt to call AppWeb.UserHTML.index/1
. If the index/1
function was not available, it would fallback to calling render/2
with the arguments of index.html
and a map of assigns.
I’m a big fan of this new approach and have been using it for quite a while now. However, this morning I ran into a problem.
The problem I was facing arose because I wanted to add sitemap.xml
, rss.xml
, and atom.xml
feeds to this website. There are a number of ways you could do this, but I choose to pass the data to a .eex
template and generate the XML like that.
First I added a new route to router.ex
:
get "/sitemap.xml", SitemapController, :index
Next I added the SitemapController
and the index
function:
defmodule CultttWeb.SitemapController do
@moduledoc """
"""
use CultttWeb, :controller
alias Culttt.Sitemap
def index(conn, _params) do
conn
|> put_resp_content_type("text/xml")
|> render("index.xml", layout: false, pages: Sitemap.pages())
end
end
As this request is XML, the next thing I did was I added a SitemapXML
module:
defmodule CultttWeb.SitemapXML do
use CultttWeb, :html
alias CultttWeb.SVG
embed_templates "sitemap_xml/*"
end
Finally, I created a new directory called sitemap_xml
and a new template named index.xml.eex
. The template has to be a .eex
file otherwise the HTML formatter complains.
However, when I tried to load the new route in a browser I was hit with an error saying the template could not be found.
After some digging I found that the embed_templates/2
macro is defined in Phoenix.Component
. Here is the definition of that macro at the time of me writing this:
defmacro embed_templates(pattern, opts \\ []) do
quote do
Phoenix.Template.compile_all(
&Phoenix.Component.__embed__/1,
Path.expand(unquote(opts)[:root] || __DIR__, __DIR__),
unquote(pattern) <> ".html"
)
end
end
As you can see, this macro is expecting templates to be defined with an extension of .html
. This obviously makes sense because Phoenix Components are HTML based.
At this point I could have given up and added a index/1
function to SitemapXML
. In some ways this would be a better approach because I could use an XML library to ensure the XML I’m generating was valid. However, I’d come this far down the path I decided to carry on and get my initial implementation to work correctly!
So clearly, we can’t use the embed_templates/2
macro from Phoenix.Component
. Instead, we’re going to have to define our own version that can work with non HTML files.
If you look at the implementation of the embed_templates/2
macro above, you will see that all it’s doing is calling the compile_all/3
function on the new Phoenix.Template
module. So to make this work, we just have to define our own version of this function.
As I didn’t have a better place to put this function, I decided to add it to my CultttWeb
module:
defmacro embed_templates(pattern, opts) do
quote do
require Phoenix.Template
Phoenix.Template.compile_all(
&(&1 |> Path.basename() |> Path.rootname() |> Path.rootname()),
Path.expand(unquote(opts)[:root] || __DIR__, __DIR__),
unquote(pattern) <> unquote(opts)[:ext]
)
end
end
As you can see, the implementation of this function is almost identical. I’ve removed Phoenix.Component.__embed__/1
and replaced it with the underlying implementation, and I’ve also removed the ".html"
extension and replaced it with a value from the opts
keyword list.
Next, I added the following function to CultttWeb
:
def xml do
quote do
import CultttWeb, only: [embed_templates: 2]
# HTML escaping functionality
import Phoenix.HTML
# Routes generation with the ~p sigil
unquote(verified_routes())
end
end
And finally, I updated SitemapXML
:
defmodule CultttWeb.SitemapXML do
use CultttWeb, :xml
embed_templates "sitemap_xml/*", ext: ".xml"
end
Now when I load the website in the browser, my XML response is rendered correctly!