cult3

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

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
    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.

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
    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!

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

    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!

Philip Brown

@philipbrown

© Yellow Flag Ltd 2024.