Building an RSS Feed with Phoenix

Posted by in Phoenix, last updated on 10 October, 2020

Introduction

I wanted to add an RSS feed to this blog but decided that instead of installing a dependency I’ll build one myself! Here’s how you can set one up in a Phoenix application:

Creating the RSS view

First let’s define a new route in lib/your_app_web/router.ex:

scope "/", YourAppWeb do
  ...

  get "/rss", RSSController, :index
end

A view module in lib/your_app_web/views/rss_view.ex:

defmodule YourAppWeb.RSSView do
  use YourAppWeb, :view
end

A controller module in lib/your_app_web/controllers/rss_controller.ex with all published posts which we’ll iterate over in the RSS view, and the most recent updated_at timestamp which we’ll use as the RSS feed’s <lastBuildDate> later on:

defmodule YourAppWeb.RSSController do
  use YourAppWeb, :controller
  plug :put_layout, false

  alias YourAppWeb.Content

  def index(conn, _params) do
    posts = Content.all_published_posts
    last_build_date = Content.most_recently_updated_post.updated_at

    conn
    |> put_resp_content_type("text/xml")
    |> render("index.xml", posts: posts, last_build_date: last_build_date)
  end
end

Note that we’re disabling layout with plug :put_layout, false for the RSS feed.

We’re also changing the response content type to "text/xml", technically "application/rss+xml" is the correct content type, however it seems "text/xml" is still the most widely supported option, you can read more about it here.

Here’s how I’ve written the most_recently_updated_post function:

def most_recently_updated_post do
  Post
  |> where([p], p.is_published == true)
  |> order_by([p], desc: p.updated_at)
  |> limit(1)
  |> Repo.one
end

Lastly create a new rss directory and index.xml.eex in lib/your_app_web/templates/rss/index.xml.eex which we’ll fill in next:

Writing the XML

For the RSS feed there are required and optional fields, you can see the full RSS 2.0 specification here.

This is the basic layout for an RSS XML document:

<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
  <channel>
    # data that is defined only once goes here
    # e.g. RSS feed name, URL, build date, language etc.

    # next we have multiple items, one for each post:
    <item>
      # within the item we'll have post attributes
      # such as title, URL, description, category
    </item>

    # next item:
    <item>
      # same fields as for previous item
    </item>
    ...
  </channel>
</rss>

Here’s the full RSS feed I built based on the variables we declared earlier in the controller:

<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>Blog Title</title>
    <link><%= Routes.blog_url(@conn, :index) %></link>
    <atom:link href="<%= Routes.rss_url(@conn, :index) %>" rel="self" type="application/rss+xml" />
    <description>Your blog's description...</description>
    <language>en</language>
    <copyright>Copyright <%= DateTime.utc_now.year %> Your Name</copyright>
    <lastBuildDate><%= @last_build_date |> to_rfc822 %></lastBuildDate>
    <category>IT/Internet/Web development</category>
    <ttl>60</ttl>

    <%= for post <- @posts do %>
      <item>
        <title><%= post.title %></title>
        <link><%= Routes.post_url(@conn, :show, post) %></link>
        <guid><%= Routes.post_url(@conn, :show, post) %></guid>
        <description><![CDATA[ <%= post.excerpt %> ]]></description>
        <category><%= post.category.name %></category>
        <pubDate><%= post.inserted_at |> to_rfc822 %></pubDate>
        <source url="<%= Routes.rss_url(@conn, :index) %>">Blog Title</source>
      </item>
    <% end %>
  </channel>
</rss>

A few things to note:

In my case post.excerpt returns HTML so I need to wrap it in an XML CDATA section: <![CDATA[ content_goes_here ]]>. The XML parser won’t parse any characters within this tag. If your content is plain text you don’t need this tag and could simply write <description><%= post.excerpt %></description>.

The number in the <ttl> section defines how long in minutes an RSS reader should wait before reading the feed again, in my case I set it to 60 minutes between reads.

You’ll notice I used a custom function called to_rfc822/1 to format DateTime fields correctly, here’s how that works:

Formatting date time fields to RFC822

I used the Timex library to format DateTime fields to RFC822. First declare Timex as a dependency in mix.exs:

defp deps do
  [
    ...
    {:timex, "~> 3.5"}
  ]
end

Then install with mix deps.get.

Now let’s create a new function in the rss_view.ex module so we can use it in our views:

defmodule YourAppWeb.RSSView do
  use YourAppWeb, :view
  use Timex

  def to_rfc822(date) do
    date
    |> Timezone.convert("GMT")
    |> Timex.format!("{WDshort}, {D} {Mshort} {YYYY} {h24}:{m}:{s} {Zname}")
  end
end

Note that Timex has an inbuilt option to format to RFC822 however it wasn’t validating correctly so I ended up writing my own formatter. I think the issue was the output of year as 4 digits versus 2 digits, here’s the difference in outputs:

date
|> Timezone.convert("GMT")
|> Timex.format!("{RFC822}")
# Wed, 27 Aug 20 11:37:46 +0000

date
|> Timezone.convert("GMT")
|> Timex.format!("{WDshort}, {D} {Mshort} {YYYY} {h24}:{m}:{s} {Zname}")
# Wed, 27 Aug 2020 11:37:46 GMT

Enabling RSS auto discovery

Next let’s add an RSS auto discovery link so that browsers and RSS readers can automatically find the new feed. To do that add the following code in your <head> tags in app.html.eex:

<link rel="alternate" type="application/rss+xml" title="Blog Title" href="<%= Routes.rss_path(@conn, :index) %>" />

Validating your new feed

Last thing you’ll want to do is validate your feed here. You can copy and paste the page source code before deploying to check for errors, and after deployment use URI option instead.

With that your feed is good to go!


Daniel Wachtel

Written by Daniel Wachtel

Daniel is a Full Stack Engineer who outside work hours is usually found working on side projects or blogging about the Software Engineering world.