Imagine we’re building an event notification system for a Rails app. The events are quite regular, usually consisting of a user performing an action on a target. There are different types of events, which determine the name of the action and overall structure of the notification text.
Because each event type should be displayed in a regular way but with a varying user and target, it makes sense for us to perform some kind of templating to build the notification text to display to our users.
The most basic way to do this is plain old string interpolation.
user = 'Bob'
target = 'Jill'
"#{user} followed #{target}"
# => "Bob followed Jill"
This works, but a downside is that the interpolation happens at the same time the string is defined. We can’t store the user and target placeholders in the string and perform the interpolation later. We might want to do this to apply styling and rewording based on the context, for instance applying styling in HTML but not plain text emails, or replacing usernames with “you” if the current user is that user.
A core Ruby feature that can help us build a more proper templating system is Kernel#sprintf (alias Kernel#format
). It allows composition of strings by formatting arguments with a regular, explicit syntax. There are a lot of options for precisely formatting numbers as strings, but this post will focus on strings.
We can do replace references by their position in the arguments:
format('%s followed %s', 'Bob', 'Jill')
# => "Bob followed Jill"
But references can also be named:
format('%{user} followed %{target}', { user: 'Bob', target: 'Jill' })
# => "Bob followed Jill"
Leveraging format
allows us to define arbitrary string templates and process them all with a single piece of formatting code, like when displaying a feed of all of a user’s notifications, which may be of different types but still generally of the same structure.
event_1 = {
objects: {
user: 'Bob',
target: 'Jill'
},
template: '%{user} followed %{target}'
}
event_2 = {
objects: {
user: 'Alice',
target: 'Doug'
},
template: '%{user} mentioned %{target}'
}
users_that_liked = ['Alice', 'Bob', 'Jill']
event_3 = {
objects: {
user: users_that_liked.first,
other_users_count: users_that_liked.count - 1,
target: 'This post'
},
template: '%{target} was liked by %{user} and %{other_users_count} others'
}
def format_event(event)
format(event[:template], event[:objects])
end
format_event(event_1)
# => "Bob followed Jill"
format_event(event_2)
# => "Alice mentioned Doug"
format_event(event_2)
# => "This post was liked by Alice and 2 others"
With the formatting concentrated in format_event
, we can alter the formatting of events and notifications across the entire application by making a change in this one place. In our example, let’s highlight the objects inserted into the template by making them bold. We just need to manipulate the objects before we pass them to format
.
include ActionView::Helpers::TagHelper
def format_event(event)
objects = event[:objects].dup.tap do |o|
o[:user] = tag.b(o[:user])
o[:target] = tag.b(o[:target])
end
format(event[:template], objects)
end
format_event(event_1)
# => "Bob followed Jill"
However, a problem arises when our objects contain user input.
event_2 = {
objects: {
user: 'Alice',
target: 'Doug',
comment: 'Hey @Doug <script>...</script>'
},
template: '%{user} mentioned %{target}: "%{comment}"'
}
format_event(event_2)
# => "<b>Alice</b> mentioned <b>Doug</b>: \"Hey @Doug <script>...</script>\""
format
always returns a String, so in order to get the styling we want, we need to mark the whole result as HTML-safe. Otherwise Rails will escape certain characters when building the final HTML, demonstrated here by appending our formatted string to an HTML-safe div.
'<div>...</div>'.html_safe + format('%s'.html_safe, tag.b('Bob'))
# => "<div>...</div><b>Bob</b>"
But if we mark the whole string as safe after the format operation, that will also mark any user input as safe.
'<div>...</div>'.html_safe + format('%s', '<script>...</script>').html_safe
# => "<div>...</div><script>...</script>"
Back to our formatting of event_2
. How can we mark the user and target as safe, but not the comment?
It turns out that ActiveSupport::SafeBuffer
, Rails’ String subclass to help manage the safety of HTML output, provides a way to do this safely.
First, note that Ruby provides a shorthand to formatting with the String method %, which can be called on the template directly. So template % objects
is equivalent to format(template, objects)
.
ActiveSupport::SafeBuffer
overrides that method, and takes care of maintaining the HTML-safe nature of any objects (like the result of tag.b
) but escaping any unsafe ones.
String#html_safe
returns an ActiveSupport::SafeBuffer
, so we just need to call that on our template to mark it safe (we defined it, after all) and it will internally handle escaping unsafe strings.
def format_event(event)
objects = event[:objects].dup.tap do |o|
o[:user] = tag.b(o[:user])
o[:target] = tag.b(o[:target])
end
event[:template].html_safe % objects
end
format_event(event_2)
# => "<b>Alice</b> mentioned <b>Doug</b>: \"Hey @Doug <script>...</script>\""
The documentation for ActiveSupport::SafeBuffer#%
doesn’t mention this handy feature. Looking at the git blame for this method, we can see that this override was originally implemented in 2012, well over a decade ago. So, as is often the case, if we stick to Rails’ core functionality (and maybe do a little spelunking) we find that it continues to provide battle-tested features to help us build safe, reliable web apps.
Loved the article? Hated it? Didn’t even read it?
We’d love to hear from you.