Embedding XML templates in Phoenix 1.7

Jan 27, 2023

Table of contents:

  1. How are responses rendered in Phoenix 1.7?
  2. Attempting to embed non-HTML templates
  3. An explanation of what was going wrong
  4. Fixing the problem

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!

How are responses rendered in Phoenix 1.7?

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/*"

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))}

  @doc """
  Renders a single users
  def show(%{user: user}) do
    %{data: data(user)}

  defp data(%User{} = user) do
    %{id:, name:}

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.

Attempting to embed non-HTML templates

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
    |> put_resp_content_type("text/xml")
    |> render("index.xml", layout: false, pages: Sitemap.pages())

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/*"

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.

An explanation of what was going wrong

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
      Path.expand(unquote(opts)[:root] || __DIR__, __DIR__),
      unquote(pattern) <> ".html"

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!

Fixing the problem

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

      &(&1 |> Path.basename() |> Path.rootname() |> Path.rootname()),
      Path.expand(unquote(opts)[:root] || __DIR__, __DIR__),
      unquote(pattern) <> unquote(opts)[:ext]

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

And finally, I updated SitemapXML:

defmodule CultttWeb.SitemapXML do
  use CultttWeb, :xml

  embed_templates "sitemap_xml/*", ext: ".xml"

Now when I load the website in the browser, my XML response is rendered correctly!

Philip Brown


© Yellow Flag Ltd 2024.