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!