Reopening classes to improve the Session interface
Posted over 4 years ago by Raul Parolari
Chapter 7 of RailsSpace refactors the management of the session login-logout feature, presenting 3 methods:
# Method calls: # Object:
user.login!(session) # User instance
User.logout!(session, cookies) # User class
logged_in? # controller
This significantly improved the initial version which disseminated in the code accesses to the session internal data structures (although there are still direct accesses to read the user_id, that we will tackle later).
However, there is something a bit odd: the object on which the methods act changes each time: it is an User instance to log in the user, then the User class to log him out, and finally it is the controller (‘self’ is the controller) to verify the log in. Could we have an uniform way to invoke those 3 actions?
Looking inside those methods, it is inmediate to realize that the real object on which the methods act is not user, but session, which belongs to the class CGI::Session (in my machine, file /usr…ruby/1.8/cgi/session.rb). If we only could add methods in there!
But wait: this is Ruby; we can always reopen a class and add functionality! very much like the book did when it reopened the class String to add the beautiful method or_else? (ch 9). Comforted by that thought, we created a file with our session extension in the lib directory (that we will ‘require’ at the top of the application.rb file):
class CGI::Session
def login!(user)
self[:user_id] = user.id
end
def logout!
self[:user_id] = nil
end
def logged_in?
not self[:user_id].nil?
end
end # CGI::Session
The content of the methods is identical as before (aside from a detail in the logout action regarding the deletion of the long term cookie, that we will perform now in the controller), and the calls are now:
# In class User Controller
# in login and register actions
session.login!(@user) # old: @user.login!(session)
# in logout action
session.logout! # old: User.logout!(session, ..)
cookies.delete(:authorization_token)
# In class ApplicationController
unless session.logged_in? # old: unless logged_in?
end
[A curious detail: observe how the place of the terms session,login,user exactly reverse in the login! call; of course, as the ‘actor’ is now the session].
The same modifications have to be done in the tests. The only place where I left ‘logged_in?’ is in the layout, as it is friendlier in that context (in the application helper, I made that method call session.logged_in?). Finally, I deleted the methods dealing with session from the User model.
We can at this point observe that it would be nice to also centralize all those accesses to the session hash (like ‘User.find(session[:user_id]’) to retrieve the user_id without exposing the session hash. We can of course achieve that adding a method ‘user_id’ to our Session extension.
But, at this point, observing that the ‘login!’ and ‘logout!’ methods are nothing else than getters/setters on the session, we become tempted to simplify things even further:
class Session_Invalid_UserId < Exception; end
class CGI::Session
# returns current user id in session
def user_id
self[:user_id]
end
# sets session to given user (or nil)
def user_id=(value)
self[:user_id] =
case value
when Fixnum: value
when User: value.id
when nil: value
else
raise Session_Invalid_UserId, "invalid user_id #{value}"
end
end
# returns true if there is a session user
def user_id?
self[:user_id] != nil
end
end # CGI::Session
The 3 invocations (session.user_id, session.user_id = @user, session.user_id?) can replace not only the calls login!, logout!, logged_in? but also the accesses to retrieve the user id. Notice also that by doing this we can now detect an error when setting the user_id to something else than a number, an user instance, or nil (for logout).
After having replaced all the strings in the app and test files (using global replaces), we reopened our browser and everything worked ok! Alas, the happiness was short-lived, as the attempt to run the test suite failed!
We discovered that the tests do not use the CGI::Session but rather a simulation of it, called ActionController::TestSession, that it is just an access to a hash called ‘data’. We could then repair the problem, by simply adding in our session_ext file an extension to that class:
class ActionController::TestSession
def user_id; data[:user_id]; end
def user_id=(value); data[:user_id] = value; end
def user_id?; data[:user_id] != nil; end
end
And the test suite ran.. fine (phew!).
Perhaps all the above work seems an unnecessary trouble (disclosure: we certainly shared that thought, when the tests failed miserably and we had not idea why!). However, in large projects it is often useful at a certain point to organize all functionalities in the most apt way (as, for some reason, entropy, or what we programmers call ‘disorder/noise’, has a subtle way to take off exponentially in software systems).
Using Ruby, this means identifying the Classes and Objects around which to coalesce functionalities.
What this exercise tried to demonstrate is that even when those classes are not under our control (like the unfamiliar CGI::Session and the unexpected TestSession), Ruby offers us the possibility to reopen those classes and extend their capabilities in the best way that fits our design.
Comments
There are 3 comments on this post.
This is nice. I also recommend looking at the restful_authentication plugin. I’ve used it on a couple sites and it works well.
what is the exact require path that you put in the application.rb file because I keep getting an error that those seesion methods are not found. NOOB =)
btw this breaks my test.
def userid=(value); data[:userid] = value; end
i had to change it to this for the test to work.
def user_id=(value) data[:user_id] = case value when User: value.id when Fixnum: value when nil: value
else raise SessionInvalidUserId, “invalid user_id #{value}” end end