Just a quick post for anyone looking to implement RSS Feeds in Crystal Lucky Framework. This post works with Lucky 0.14.1 but it should work with 0.15 as well.
Thanks to @paulcsmith and @jeremywoertink for helping me work this out in Gitter.
First, create an Action
that inherits from Lucky::Action
. We’ll add a method called xml
which can be called in each of your actions, passing in the data for the feed. The xml
method will then create the xml string with Crystal’s built in XML Builder and iterate over the data that you pass in.
# src/actions/xml_action.cr
require "xml"
abstract class XMLAction < Lucky::Action
def title
"Website RSS Feed"
end
def description
"Updates for Website"
end
def link
"https://websiteurl.dev"
end
private def xml(articles : ArticleQuery)
string = XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element(
"rss",
version: "2.0",
"xmlns:dc": "http://purl.org/dc/elements/1.1/",
"xmlns:content": "http://purl.org/rss/1.0/modules/content/",
"xmlns:atom": "http://www.w3.org/2005/Atom",
"xmlns:media": "http://search.yahoo.com/mrss/"
) do
xml.element("channel") do
xml.element("title") { xml.cdata title }
xml.element("description") { xml.cdata description }
xml.element("link") { xml.text link }
xml.element("generator") { xml.text "Lucky Framework" }
xml.element("lastBuildDate") { xml.text Time.utc_now.to_s }
xml.element("atom:link") {
xml.attribute "href", "#{link}#{request.path}"
xml.attribute "rel", "self"
xml.attribute "type", "application/rss+xml"
}
xml.element("ttl") { xml.text "60" }
articles.each do |article|
xml.element("item") do
# title, description, link, category, dc:creator, pubDate, content:encoded
xml.element("title") { xml.cdata article.title }
if article.meta_description
xml.element("description") { xml.cdata article.meta_description.not_nil! }
end
xml.element("link") { xml.text "#{link}articles/#{article.slug}" } xml.element("dc:creator") { xml.cdata "Author Name" }
xml.element("pubDate") { xml.text article.created_at.to_s }
if article.og_image
xml.element("media:content") do
xml.attribute "url", "article.og_image"
xml.attribute "medium", "image"
end
end
if article.content
content = Markdown.to_html(article.content.not_nil!)
xml.element("content:encoded") { xml.cdata content }
end
end
end
end
end
end
Lucky::TextResponse.new(context, content_type: "text/xml; charset=utf-8", body: string status: 200)
end
end
Although this abstract class looks quite large it’s not really doing much except generating the XML to output to the browser. By doing this it will greatly simplify our action classes.
In the example we’re passing in an ArticleQuery
which you will need to implement in your own app. For reference, mine has a scope for published
so I can keep unpublished articles from being viewed.
class ArticleQuery < Article::BaseQuery
def published
published_at.lte(Time.now)
end
end
The Article
model has the following attributes: title
, meta_description
(optional), slug
, content
(optional), og_image
(optional). You will need to update this for your own purposes as well.
At the end of the XMLAction#xml
method we’re using Lucky::TextResponse
to send the XML string to the browser. Note that application/rss+xml
is the proper content type to respond with, however, if you want it to be viewable in a web browser you need to use text/xml; charset=utf-8
(This is how Ghost does it at least).
Example Action
Since all the XML logic is in the XMLAction
we can make our actions really clean. In the example below Rss::Index
overrides the feed title and then passes in a list of published articles through the xml
method.
# src/actions/rss/index.cr
class Rss::Index < ::XMLAction
def title
"Latest Dailies"
end
get "/feeds/all.rss" do
articles = ArticleQuery.new.published.published_at.desc_order
xml articles
end
end
Creating a Serializer
Although we have a working RSS feed, we can go one step further and create an ArticleXmlSerializer
to handle the XML, rather than using the XMLAction
.
We'll pass this Serializer output to the modified XMLAction
# src/actions/xml_action.cr
abstract class XMLAction < Lucky::Action
private def xml(body : String)
Lucky::TextResponse.new(context, content_type: "text/xml; charset=utf-8", body: body, status: 200)
end
end
# src/serializers/article_xml_serializer.cr
require "xml"
class ArticlesXmlSerializer < Lucky::Serializer
property title : String = ""
property description : String = ""
property base_url : String = "https://example.dev"
property path : String = ""
# You can set the instance vars directly. This elininates some code and also is now compile time safe!
# That means if you forget one of these arguments, it is the wrong type, or you have a typo, Crystal will let
# you know at compile-time
def initialize(@articles : ArticleQuery, @title, @description, @base_url, @path)
end
def render
XML.build(indent: " ", encoding: "UTF-8") do |xml|
xml.element(
"rss",
version: "2.0",
"xmlns:dc": "http://purl.org/dc/elements/1.1/",
"xmlns:content": "http://purl.org/rss/1.0/modules/content/",
"xmlns:atom": "http://www.w3.org/2005/Atom",
"xmlns:media": "http://search.yahoo.com/mrss/"
) do
xml.element("channel") do
xml.element("title") { xml.cdata title }
xml.element("description") { xml.cdata description }
xml.element("link") { xml.text base_url }
xml.element("generator") { xml.text "Solo" }
xml.element("lastBuildDate") { xml.text Time.utc_now.to_s }
xml.element("atom:link") {
xml.attribute "href", "#{base_url}#{path}"
xml.attribute "rel", "self"
xml.attribute "type", "application/rss+xml"
}
xml.element("ttl") { xml.text "60" }
@articles.each do |article|
xml.element("item") do
xml.element("title") { xml.cdata article.title }
if article.meta_description
xml.element("description") { xml.cdata article.meta_description.not_nil! }
end
xml.element("link") { xml.text "#{base_url}/articles/#{article.slug}" }
xml.element("dc:creator") { xml.cdata "Author Name" }
xml.element("pubDate") { xml.text article.created_at.to_s }
if article.og_image
xml.element("media:content") do
xml.attribute "url", "article.og_image"
xml.attribute "medium", "image"
end
end
if article.content
content = Markdown.to_html(article.content.not_nil!)
xml.element("content:encoded") { xml.cdata content }
end
end
end
end
end
end
end
end
Finally, here's the new action
# src/actions/rss/index.cr
class Rss::Index < ::XMLAction
get "/feeds/all.rss" do
articles = ArticleQuery.new.published.published_at.desc_order
xml ArticlesXmlSerializer.new(articles, title: "Feed Name", description: "Here's the descr", path: request.path).render
end
end