A Dall-E generated image featuring a landscape photo of the moon, with two spikes and two planets in the backgroundA landscape photo of a moonbase, with the earth in the background, digital art

I recently ported this blog from Remix to Leptos, and I'd like to maintain feature parity. One of the features I added by request was an RSS feed, to let people follow my posts. There probably aren't too many of you using it, but let's implement it for Axum.

Each of my blog Posts are converted into a Post type in my backend, which looks like this

Rust code
pub struct Post {
    pub title: String,
    pub slug: String,
    pub excerpt: Option<String>,
    pub content: String,
    pub toc: Option<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub published: bool,
    pub preview: bool,
    pub links: Option<String>,
    pub tags: Vec<String>,
}

RSS Format

Rss Feeds are an XML document, so all we need to do is generate that and populate it with entries for each of the blog posts. Here's all the info we'd need for each post.

Rust code
pub struct RssEntry {
    pub title: String,
    pub link: String,
    pub description: Option<String>,
    pub pub_date: String,
    pub author: String,
    pub guid: String,
}

Since we have a Post that has all the data we need and want an RssEntry, let's impl From from one to the other

Rust code
impl From<Post> for RssEntry {
    fn from(post: Post) -> Self {
        let full_url = format!("https://benw.is/posts/{}", post.slug);
        Self {
            title: post.title,
            link: full_url.clone(),
            description: post.excerpt,
            pub_date: post.created_at.to_rfc2822(),
            author: "benwis".to_string(),
            guid: full_url,
        }
    }
}

Now we can implement a function to convert our RssEntry into xml tags

Rust code
impl RssEntry {
    // Converts an RSSEntry to a String containing the rss item tags
    pub fn to_item(&self) -> String {
        format!(
r#"
        <item>
            <title><![CDATA[{}]]></title>
            <description><![CDATA[{}]]></description>
            <pubDate>{}</pubDate>
            <link>{}</link>
            <guid isPermaLink="true">{}</guid>
        </item>
      "#,
            self.title,
            self.description.clone().unwrap_or_default(),
            self.pub_date,
            self.guid,
            self.guid
        )
    }
}

The <item> tag is used to represent each post, and we insert data into each tag. Don't ask me why XML does what it does, I don't know. Most of the names here are self explanatory, but the guid tag is supposed to be a unique identifier for the content. If isPermaLink is true, it'll probably assume that the ID is a url. The default for it is true, so it's technically not required here.

So now we have an XML representation of each post, we need to generate the outer XML document. I chose to encapsulate the generation of the document with the items inside into a function, although it's not required.

Rust code
pub fn generate_rss(
    title: &str,
    description: &str,
    link: &str,
    posts: &IndexMap<String, Post>,
) -> String {
		// Let's generate all those XML tags for Posts and collect them into a single string
    let rss_entries = posts
        .clone()
        .into_values()
        .filter(|p| p.published)
        .map(|p| p.into())
        .map(|r: RssEntry| r.to_item()) // Here we're using that handy function to convert to a String
        .collect::<String>(); // You can collect to a String, isn't that neat?

    format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>{title}</title>
        <description>{description}</description>
        <link>{link}</link>
        <language>en-us</language>
        <ttl>60</ttl>
        <atom:link href="https://benw.is/rss.xml" rel="self" type="application/rss+xml" />
        {}
    </channel>
</rss>   
     "#,
        rss_entries
    )
}

It was pointed out to me by @jsm on the Leptos discord that this code contains the potential for XML injection that might cause issues for any vulnerable RSS Readers or XML parsers that read this. This could be an issue if anyone untrusted can put in title and description info. To combat this, let's add some sanitization to the title and description tags right before we insert them into the RSS XML. This means replacing the problematic < and * characters, with their escaped eqivalents. Thanks @jsm!

Rust code
    use xml::escape::escape_str_pcdata;
		// It's possible to insert XML injection attacks that might affect the RSS readers
    // if untrusted people can put in title, description, or link. To solve that,
    // we can escape these inputs
    let safe_title = escape_str_pcdata(title);
    let safe_description = escape_str_pcdata(description);
    format!(
  r#"<?xml version="1.0" encoding="UTF-8"?>
  <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
      <channel>
          <title>{safe_title}</title>
          <description>{safe_description}</description>
          <link>{link}</link>
          <language>en-us</language>
          <ttl>60</ttl>
          <atom:link href="https://benw.is/rss.xml" rel="self" type="application/rss+xml" />
          {}
      </channel>
  </rss>   
       "#,
          rss_entries
      )

Most of the boilerplate is fixed, we want an xml tag at the very front of the page, an rss tag that contains everything, and a channel tag containing a number of tags describing what the feed is for, and the items within. So far so good!

Now all we need to do is serve the rss.xml file, which we can do with an Axum handler. It's really neat how Axum implies IntoResponse for a wide variety of Rust types, so all we need to do is call our generate_rss() function with our parameters, and then return it. Axum will take our String, and turn it into a Response with the contents as HTML!

Rust code
pub async fn rss_page(State(app_state): State<AppState>) -> impl IntoResponse {
    // list of posts is loaded from the server in reaction to changes
    let raw_posts = app_state.posts;
    let reader = raw_posts.0.read();
    generate_rss(
        "benwis Blog",
        "The potentially misguided ramblings of a Rust developer flailing around on the web",
        "http://benw.is",
        &reader.posts,
    )
}
        }
    }

I store my Posts inside Axum's State so that they can be accesed by every handler, in a container type that looks like Container(Arc<RwLock<Posts>), so you might be able to simplify this still further.

Add a new route to our Router, and we should be just about done.

Rust code
let router=Router::new()
    .route("/rss.xml", get(rss_page))
...

Let's see what that looks like by going to https://benw.is/rss.xml

XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>benwis Blog</title>
        <description>The potentially misguided ramblings of a Rust developer flailing around on the web</description>
        <link>http://benw.is</link>
        <language>en-us</language>
        <ttl>60</ttl>
        <atom:link href="https://benw.is/rss.xml" rel="self" type="application/rss+xml" />
        
        <item>
            <title><![CDATA[A Rite of Passage: Compiling Markdown]]></title>
            <description><![CDATA[Odds are, if you're a web developer like me, you've tried to build a developer blog. And if you've done that before, you've probably experienced the *delight* that is Markdown parsing and compilation. But it's never just Markdown parsing, it's about everything else surrounding it. ]]></description>
            <pubDate>Tue, 29 Nov 2022 08:00:00 +0000</pubDate>
            <link>https://benw.is/posts/compiling-markdown</link>
            <guid>https://benw.is/posts/compiling-markdown</guid>
        </item>
      
        <item>
            <title><![CDATA[Bridging the Server<|_|>Client Divide]]></title>
            <description><![CDATA[If you've been paying attention to Web Dev Twitter, it'd be hard to miss the increasing number of tweets decrying the choice of GraphQL for their applications. and how much better tRPC or their framework is. Let's dig into that.]]></description>
            <pubDate>Sat, 29 Oct 2022 08:00:00 +0000</pubDate>
            <link>https://benw.is/posts/bridging-the-divide</link>
            <guid>https://benw.is/posts/bridging-the-divide</guid>
        </item>
      
        <item>
            <title><![CDATA[Webassembly in Remix]]></title>
            <description><![CDATA[Today is a momentous day for me, a culmination of nearly seven months of waiting. My pull request adding Webassembly(often abbreviated as WASM) support to Remix has finally been merged! But how we do we use it?]]></description>
            <pubDate>Thu, 13 Oct 2022 08:00:00 +0000</pubDate>
            <link>https://benw.is/posts/webassembly-on-remix</link>
            <guid>https://benw.is/posts/webassembly-on-remix</guid>
        </item>
      
        <item>
            <title><![CDATA[Better Web Apps on the Desktop]]></title>
            <description><![CDATA[The last couple decades have seen a trend, a trend away from native desktop apps, towards web and mobile apps. Then Electron came along, and made web apps run on the desktop. But the apps were slow and clunky, perhaps there's a better way.]]></description>
            <pubDate>Wed, 5 Oct 2022 08:00:00 +0000</pubDate>
            <link>https://benw.is/posts/better-desktop-web-apps</link>
            <guid>https://benw.is/posts/better-desktop-web-apps</guid>
        </item>
      
        <item>
            <title><![CDATA[The Gist of gRPC]]></title>
            <description><![CDATA[Get the gist of gRPC, the high throughput, lightweight, and multilanguage API framework! Great for projects that do video, streaming, and more!]]></description>
            <pubDate>Mon, 19 Sep 2022 08:00:00 +0000</pubDate>
            <link>https://benw.is/posts/the-gist-of-grpc</link>
            <guid>https://benw.is/posts/the-gist-of-grpc</guid>
        </item>
      
        <item>
            <title><![CDATA[ReNixing My OS]]></title>
            <description><![CDATA[Have you heard of Nix and NixOS? Do you dream of config files? Learn how to use NixOS to make a better dev workstation, and you the greatest, most interesting, most important developer in the realm]]></description>
            <pubDate>Fri, 2 Sep 2022 08:00:00 +0000</pubDate>
            <link>https://benw.is/posts/renixing-my-os</link>
            <guid>https://benw.is/posts/renixing-my-os</guid>
        </item>
      
        <item>
            <title><![CDATA[Serving Static Files With Axum]]></title>
            <description><![CDATA[Perhaps you'd like to run a fileserver with Axum, or maybe just serve static content in a folder. It's a bit trickier in Axum than you might expect]]></description>
            <pubDate>Thu, 18 Aug 2022 08:00:00 +0000</pubDate>
            <link>https://benw.is/posts/serving-static-files-with-axum</link>
            <guid>https://benw.is/posts/serving-static-files-with-axum</guid>
        </item>
      
    </channel>
</rss>   
     

Success!

Leptos Notes

There are some potential pitfalls to this approach if you're using Leptos. The biggest one comes from Axum's top down method of passing data. If you fetch your Posts inside Leptos itself, say in a server function, you won't be able to use that function or that post data to render the xml. You'd have to either duplicate or move that logic up into the Axum layer, and then figure out an update procedure for it from inside Leptos. You can avoid this by getting your content inside Axum and passing it mutably to Leptos, but that can be painful if you've already set everything up that way. Unforunately, there seems to be no way to return pure html/xml from inside Leptos.

I also ran into an issue where if I clicked my RSS icon, it would load a blank page when rendering on the client. A refresh would contact the server and work normally. You can tell Leptos to always server render that by adding rel="external" to your link tag, which is a neat trick.

HTML
<a rel="external" href="https://benw.is/rss.xml">RSS</a>

Wrapping Up

Hopefully that was helpful for others trying to implement an RSS feed for Axum or Leptos. Feel free to reach out if you have questions, either personally or on the Leptos discord. Someone should be available to help.