Nithin Bekal wrote an excellent post on how to use gradual engagement in Ruby on Rails for guest users by allowing them to publish posts to a site without actually registering.
This post presents a database-less approach to handling guest users in Rails. It’s not necessarily better than Nithin’s approach, but the general idea is not to bother our database with guest users’ data which eventually has to be cleaned up manually. There also should not be a special treatment for the guest users by the controllers, views and other models.
This leads to a cookie or session based approach to store a guest users’ data. A cookie based approach means they cannot access the data from different devices. This leaves an incentive for them to actually register, while it’s possible to try out the personalized functionality of the website. It’s also a good option to have an expiration date on the cookie, so guests have to come back in order to keep their data.
Basic Application Structure
The application is quite vanilla. Registered users can upload videos, and add videos to a watchlist to watch them at a later time. Watchlisted videos are represented by the SavedVideo
model. Devise is used for registration and authentication.
# app/models/user.rb
class User < ActiveRecord::Base
has_many :saved_videos
devise :database_authenticatable, :registerable
end
# app/models/saved_video.rb
class SavedVideo < ActiveRecord::Base
belongs_to :video
belongs_to :user
scope :for_video, ->(video) { where(video: video) }
end
Registered users can also view their watchlist. The SavedVideosController
takes care of this and adding videos on the watchlist:
# app/controllers/saved_videos_controller.rb
class SavedVideosController < ApplicationController
before_action :authenticate_user!
def index
@saved_videos = current_user.saved_videos
respond_to(:html)
end
def create
video = Video.find(params[:video_id])
current_user.saved_videos.create!(video: video)
flash.notice = 'Video saved for later.'
respond_to do |format|
format.html { redirect_to(videos_path) }
end
end
end
The watchlist page looks like this:
# app/views/saved_videos/index.html.slim
.row
.cols-xs-12
h1 Saved Videos
table.table.table-striped
= render(@saved_videos) || 'No videos saved for later yet'
The saved video partial simply renders the video with an additional surrounding tag for styling purposes:
# app/views/saved_videos/_saved_video.html.slim
.saved-video
= render(saved_video.video)
The video partial also has a link to add a video to the watchlist, or shows a label if a video is already saved for later:
# app/views/videos/_video.html.slim
.video
h2 = video.name
- if current_user.saved_videos.for_video(video).exists?
span.label.label-primary On Watchlist
- else
a href=video_saved_video_path(video) data-method="post" Watch later
The page of all available videos looks like this when adding a video to the watchlist:
Refactoring User
Before moving on to the implementation of the guest user, the interface of the User
model has to be simplified. Right now it looks rather complicated:
current_user.saved_videos.for_video(video).exists?
current_user.saved_videos.create!(video: video)
current_user.saved_videos
The first two are using the ActiveRecord API. The guest user won’t be having a database backend, so the ActiveRecord specific calls should be considered an implementation detail, and need to be encapsulated.
This leads to the following structure for User
:
class User < ActiveRecord::Base
has_many :saved_videos
devise :database_authenticatable, :registerable
def saved_for_later?(video)
saved_videos.for_video(video).exists?
end
def save_for_later(video)
saved_videos.create!(video: video)
end
end
Communication with the user now happens through a few simple calls:
current_user.saved_for_later?(video)
current_user.save_for_later(video)
current_user.saved_videos
Time to apply these changes to the controller:
class SavedVideosController < ApplicationController
before_action :authenticate_user!
# ...
def create
video = Video.find(params[:video_id])
current_user.save_for_later(video)
flash.notice = 'Video saved for later.'
respond_to do |format|
format.html { redirect_to(videos_path) }
end
end
end
And the video partial:
.video
h2 = video.name
- if current_user.saved_for_later?(video)
span.label.label-primary On Watchlist
- else
a href=video_saved_video_path(video) data-method="post" Watch later
To make sure guest users can actually access the saved video controller, the before_action
call should be removed, otherwise they’ll end up on the sign in page.
Adding the Guest
Preparations have been made for the introduction of the guest user. Let’s log out and see what happens when viewing the video listing:
ActionView::Template::Error (undefined method `saved_for_later?' for nil:NilClass):
2: h2
3: = video.name
4: small<
5: - if current_user.saved_for_later?(video)
6: span.label.label-primary On Watchlist
7: - else
8: a href=video_saved_video_path(video) data-method="post" Watch later
app/views/videos/_video.html.slim:5:in `_app_views_videos__video_html_slim__3946905160582581419_70363594171520'
app/views/videos/index.html.slim:5:in `_app_views_videos_index_html_slim___3933902596725860868_70363542529440'
app/controllers/videos_controller.rb:7:in `index'
Devise exposes the logged in user in a controller method called current_user
. If the user is not authenticated, current_user
simply returns nil
, leading to the error. So instead of nil
, it should return the guest user object.
Let’s initially implement it as a Null Object. It won’t do anything yet, but this is a useful way to see if we haven’t missed anything else.
class Guest
def saved_for_later?(video)
false
end
def save_for_later(video)
end
def saved_videos
[]
end
end
Override the current_user
method in the controller to return the guest user when nil
is returned:
class ApplicationController < ActionController::Base
def current_user
super || guest_user
end
private
def guest_user
@guest ||= Guest.new
end
end
Let’s check out the application again. Viewing and adding saved videos should work again. Note that when saving a video, it displays the flash message, but it doesn’t actually add it to the watchlist yet.
There’s something else odd going on too. It displays the option to log out, but we’re a guest user!
The default way in Devise to check if a user is signed in is with the user_signed_in?
method. It simply checks the presence of a current user, so even the guest object would make it return true. The easiest fix is to override the user_signed_in?
method, like so:
class User < ActiveRecord::Base
# ...
def registered?
true
end
end
class Guest
# ...
def registered?
false
end
end
class ApplicationController < ActionController::Base
# ...
def user_signed_in?
current_user.registered?
end
end
With that out of the way, it’s time to store a guests’ saved videos in a cookie!
class Guest
def initialize(store)
@store = store
end
def registered?
false
end
def saved_for_later?(video)
saved_video_ids.include?(video.id)
end
def save_for_later(video)
@store[:saved_for_later] = JSON.generate((saved_video_ids << video.id))
end
def saved_videos
[]
end
private
def saved_video_ids
return [] unless @store[:saved_for_later]
JSON.parse(@store[:saved_for_later])
end
end
The cookie store is being passed into the initializer, and the ID’s of saved videos are stored inside it in the JSON format. Since the cookie store is a very Hash-like object, the Guest
actually doesn’t really have to care that it’s a cookie store, it just pretends it’s a hash! This is useful when testing Guest
, an actual Hash
can be used:
class GuestTest < MiniTest::Test
def test_save_video_for_later
store = {}
video = mock
guest = Guest.new(store)
video.expects(:id).returns(23)
guest.save_for_later(video)
assert_equal(store, { saved_for_later: '[23]' })
end
end
In ApplicationController
, a guest should now be initialized with a cookie store:
class ApplicationController < ActionController::Base
def user_signed_in?
current_user.registered?
end
def current_user
super || guest_user
end
private
def guest_user
@guest ||= Guest.new(cookies.signed)
end
end
Let’s check out the changes made to the SavedVideosController
:
class SavedVideosController < ApplicationController
# ...
def create
video = Video.find(params[:video_id])
current_user.save_for_later(video)
flash.notice = 'Video saved for later.'
respond_to do |format|
format.html { redirect_to(videos_path) }
end
end
end
That’s right, nothing changed! This is polymorphism at its best! The create
action doesn’t waste its time caring whether the user is a guest or not. The lack of conditionals greatly boosts the clarity of the controller action!
The only thing remaining is to display the videos on the watchlist. The saved_videos
method of a user is expected to return an array-like structure with objects responding to the user
and video
methods. This is handled by returning a struct:
class Guest
# ...
def saved_videos
saved_video = Struct.new(:user, :video) do
def to_partial_path
SavedVideo.new.to_partial_path
end
end
Video.where(id: saved_video_ids).map do |video|
saved_video.new(self, video)
end
end
private
def saved_video_ids
return [] unless @store[:saved_for_later]
JSON.parse(@store[:saved_for_later])
end
end
Note that the struct also responds to to_partial_path
, so that Rails knows which partial to render for the object. Let’s see how it works:
Way cool! A guest user save videos the exact same way registered users can. This is nice to quickly try it out, without having to create an account first. After they register, everything still works the same, familiar way.
Refactoring the Guest
Looking at the Guest
class, all it really does is managing the saved videos:
class Guest
def initialize(store)
@store = store
end
def registered?
false
end
def saved_for_later?(video)
saved_video_ids.include?(video.id)
end
def save_for_later(video)
@store[:saved_for_later] = JSON.generate((saved_video_ids << video.id))
end
def saved_videos
saved_video = Struct.new(:user, :video) do
def to_partial_path
SavedVideo.new.to_partial_path
end
end
Video.where(id: saved_video_ids).map do |video|
saved_video.new(self, video)
end
end
private
def saved_video_ids
return [] unless @store[:saved_for_later]
JSON.parse(@store[:saved_for_later])
end
end
If the guest were to do anything in addition, the class becomes bloated really quickly. Let’s fast-forward a couple of refactorings to see a better structure:
# app/models/guest.rb
class Guest
attr_reader :saved_videos
def initialize(store)
@saved_videos = Guests::SavedVideos.new(store)
end
def registered?
false
end
def saved_for_later?(video)
saved_videos.include?(video)
end
def save_for_later(video)
return if saved_for_later?(video)
saved_videos << video
end
end
SavedVideos
just got its own, dedicated class. Note that it’s located inside the Guests
module. Here’s the code:
# app/models/guests/saved_videos.rb
module Guests
class SavedVideos
include Enumerable
delegate :each, to: :to_ary
def initialize(store)
@store = store
end
def include?(video)
ids.include?(video.id)
end
def <<(video)
@store[:saved_for_later] = JSON.generate(ids << video.id)
end
# Is called by ActionView when rendering a collection
def to_ary
Video.where(id: ids).map do |video|
Guests::SavedVideo.new(self, video)
end
end
private
def ids
return [] unless @store[:saved_for_later]
JSON.parse(@store[:saved_for_later])
end
end
end
The low level code of storing data inside the cookie store is in here. Take note of the to_ary
method. The struct has been replaced with an actual class. to_ary
is also called by ActionView when passing a collection to render
.
Guest
can treat Guests::SavedVideos
as an array like object, not having to worry about all the implementation details. Guests::SavedVideos
is also Enumerable
, so it actually has a lot of methods arrays do too! This makes it easy to add a little count of saved videos on the website, simply by calling current_user.saved_videos.count
!
Next steps
You might want to add restrictions to the guest user, such as a maximum amount of saved videos. And when they exceed the maximum, present them with a register screen to continue saving more videos. I’ll probably cover this in a future post.
When a guest decides to actually register, it makes sense to import their already saved data into the account as a courtesy.
Since cookies are transfered with every request to the web server, you might decide to store the data inside the session store. This can be accomplished by one simple change in the controller:
class ApplicationController < ActionController::Base
def current_user
super || guest_user
end
private
def guest_user
@guest ||= Guest.new(session)
end
end
And that’s where Rails shines, the session store has the same hash-like interface as a cookie store, making this change extremely simple!
You might also enable users to erase their data. On the User
, it’s easy enough to call destroy
or delete
. It’s not too hard to implement this for the Guest
too:
class Guest
# ...
def destroy
saved_videos.destroy
end
alias_method :delete, :destroy
end
Guests::SavedVideos
is then concerned with cleaning up only its own data:
module Guests
class SavedVideos
# ...
def destroy
@store[:saved_for_later] = nil
end
end
end
It doesn’t fully remove the cookie, but it does clear the data. For whatever reason, you cannot call delete
on a chained cookie…
Conclusion
This approach works nicely when no guest user data has to be stored inside the database (and even if it does, this can work with a few adjustments!), and you’re not storing a lot of data inside the cookie.
The greatest benefit in my opinion is that the application remains pretty much unchanged and the implementation details are nicely encapsulated by the Guest
class.
Check out the full source code on Github!