Finding the right representation
Note: If you haven't read first part of this series, start there. It explains the basics of how and whys behind this one.
As I said before, we used current_user == :false
to indicate that we checked whether we can log in user for this request, and found out we cannot - meaning it's "anonymous user", or "guest".
We tried to think and code different approaches - for some time we played with having AnonymousUser
class, or NilUser
(borrowing from nil object pattern - read more here or here), to finally find an approach we liked - using decorated object.
What that means? The idea is simple - what if current_user
was not three different types of objects (User, AnonymousUser, nil), but just one - let's call it CurrentUser
. Here is some (incomplete) code:
# app/models/current_user.rb
class CurrentUser
attr_reader :user
delegate_missing_to :user, allow_nil: true
def initialize(user)
@user = user
end
def authenticated? = user.present?
# Or equivalent, if you are not fan of Ruby endless methods:
# def authenticated?
# user.present?
# end
end
First thing you might notice is that this is PORO (plain old ruby object) - no inheritance, no composition.
Its usage is very simple:
def log_in(user)
@current_user = CurrentUser.new(user)
end
So - we will try to always have some instance of CurrentUser in our current_user
attribute, which will answer if user is authenticated?
.
This is great improvement in that we can lean much more on duck-typing - our older code often looked like this:
if current_user && current_user != false
# or sometimes just
if current_user == :false
# or some variations
We would much prefer to be confident that our current_user
can always tell us - is the user authenticated?
So, that's improvement.
Also, as we used delegate_missing_to :user
, in cases when we are interested in other user information (name, email or any other user attributes/methods), we can interact with current_user
without having a clue what it really is - if it walks like a user, and quacks like a user, you know the rest. The allow_nil: true
part helps us even when there is no user - it will just respond with nil
, which is legitimate value in cases like name or email (we would probably want to specify some false
values on some attributes, but more on that later).
Summary:
We used Decorator pattern to achieve duck-typed interface when interacting with current_user object.
I was happy with this approach, changed places where current_user was assigned to use CurrentUser, and ran test suite.
Of course, it was massively failing. Why? Now we get to some bit more interesting and/or confusing parts of Rails.
Using our PORO in associations
It's pretty common to want use the current_user
in association.
Example:
Log.create(kind:, initiator: current_user)
This failed on ActiveRecord::AssociationTypeMismatch
error. What does that mean?
Rails/ActiveRecord knows, that initiator should be a user, as we probably defined something like this:
# app/models/log.rb
class Log < ApplicationRecord
belongs_to :initiator, class_name: "User"
end
or, more likely, we even have just
# app/models/some_object.rb
class SomeObject < ApplicationRecord
belongs_to :user
end
, where the class is implicit.
So, when we try to use instance of different class, ActiveRecord is trying to save us from our obvious mistake, and doesn't allow that.
There was one way to sidestep it, which is ugly, but I will mention it nonetheless:
If we wrote it like this:
Log.create(kind:, initiator_id: current_user.id)
, it will work.
But don't that, and try to fool ActiveRecord instead!
This was probably most difficult part of all this where I was properly frustrated and claimed defeat multiple times.
In the end (as it often is), it was not so difficult when I (with help with my team leader and many people on the internet) realized two things I was missing:
1) delegation
I had delegation set by delegate_missing_to
, but I didn't realize this only does instance methods delegation, and I also wanted to delegate Class methods, to behave like the User in all ways. So many class methods
Once I understood, solution was very simple:
class CurrentUser
...
class << self
delegate_missing_to :User
end
...
end
2) pretense
This solved most problems but one - I still needed to convince ActiveRecord that yes, this really is a User object, wink wink just trust me.
For this I had to do something bit nasty:
class CurrentUser
...
delegate :is_a?, to: :user, allow_nil: true # This prevents ActiveRecord::AssociationTypeMismatch
...
end
This didn't happen automatically with delegate_missing_to
, as it was not missing - it was defined by inheriting from Object
class.
When we do this, CurrentUser is really not easily recognizable from User. It has some drawbacks - we cannot use is_a?
, as it will not tell the truth (but there are not many valid uses of that anyway).
Now we can use CurrentUser object instead of a User object all around the place, and ActiveRecord won't be any wiser.
Summary:
I learned a bit more about ActiveRecord magic, and how to bend it to my will.
comparing to other objects
Another thing that we often do with our current_user
is to compare it with other objects, like this:
current_user == post.creator
Well, even though current_user
behaves as a user in many ways, this will not be true:
<CurrentUser...> is not equal to <User...>
To solve this, we will again delegate:
class CurrentUser
delegate :==, to: :user # This allows us to compare to proper User objects
...
end
And voila, they are equal.
What happens when we change the order - post.creator == current_user
?
You guessed it, again we get false. I made it work this way, but I'm not super happy about it:
class User < ApplicationRecord
...
def ==(other)
if other.is_a?(User) && other.respond_to?(:user)
# This is here to be able to compare to CurrentUser decorator
super(other.user)
else
super
end
end
...
end
Yeah, not great. First thing, it will make every comparison to user bit slower.
Another is, User now knows too much - it expect there to be some object or objects that pretend to be of User class, and respond to user
method.
This might bring us grief later, so I'm still looking for better way. If you have any ideas, let me know please!
This solved some other failing tests, and showed another problem, and another way Rails use objects.
using with ActiveJob
Next error I got from my tests was Serialization Error for ActiveJob.
What is that? Well, when I want to create ActiveJob, using current_user as an argument, Rails needs to send identifier of the object to ActiveJob, which is then able to pick the object up correctly.
For that, I had to add custom ActiveJob serializer:
# app/serializers/current_user_serializer.rb
class CurrentUserSerializer < ActiveJob::Serializers::ObjectSerializer
def serialize?(argument)
argument.respond_to?(:current_user_decorator?) && argument.current_user_decorator?
end
def serialize(current_user)
super("user" => current_user.user)
end
def deserialize(hash)
CurrentUser.new(hash["user"])
end
end
Now this is one case when we really need to recognize if the object is CurrentUser (and, as you remember from previous part, we lost the ability to use is_a?
), so we added current_user_decorator?
method to it. It's a bit suspicious code, having to check it this way, but I didn't yet find any better.
After loading this into app (as explained in Rails guide), ActiveJob knows how to work with CurrentUser.
4 - url helpers
Last problem I encountered with decorator was with url helpers.
When you pass ActiveRecord objects to url_helpers, it is able to make urls from them:
user_path(user) => /user/123
But when I pass random object, it goes terribly wrong:
user_path(current_user) => /user/<CurrentUser...>
.
We could once again go around this problem with explicitly using id
(user_path(current_user.id)
generates correct url), but we don't want to lose this flexibility Rails provides us with.
Luckily, solutions is extremely simple here - url helpers use to_param
method to do this neat trick with converting object to id.
So we just need to define or delegate to_param
on our CurrentUser object:
# either
def to_param = id.to_s
# or
delegate :to_param, to: :user, allow_nil: true
With this, I got to passing test suite, which was a nice sight (as I started with hundreds of failing tests). Maybe we will find some other troubles (I'm thinking about ActionMail).
So, we can be almost happy with what our current CurrentUser does.
Almost.
stacking dolls situation
There are multiple places where we can change the current_user with code like current_user = Current.user(user)
. But, as we started to pass CurrentUser as a User all around our app, we are not sure (and we did a lot of work not to care about it) whether user is User or CurrentUser.
So, what happens when we create new CurrentUser, but pass another CurrentUser as a user?
We get a stacking doll situation - we can have current_user, which has CurrentUser as a user, which again has CurrentUser as a user, which...
Maybe it's not that much of a problem with delegation, but it sure is not great, so let's get rid of that.
Let's look at our CurrentUser initiation:
# app/models/current_user.rb, simplified
class CurrentUser
attr_reader :user
def initialize(user)
@user = user
end
def current_user_decorator? = true
end
and think for a moment what we can expect to get:
-
nil
, when user is anonymous - instance of
User
, when user is known - instance of
CurrentUser
, when passing current_user around
With this understanding, we can change our code to something like this:
def initialize(user = nil)
@user = if user.nil?
nil
elsif user.respond_to?(:current_user_decorator?)
user.user # This prevents stacked CurrentUser objects
elsif user.is_a?(User)
user
else
raise ArgumentError, "Only nil, or instance of User or CurrentUser can be passed"
end
end
We solved two things here - we "unstack" current_user, and we validate the input (so we cannot pass other objects to it by mistake).
Again we used our current_user_decorator?
method to recognize instances of CurrentUser.
All right, that's enough for now. This was really long (maybe I should split this to two posts), and we did a lot of work.
There are still some changes and improvements in our pipeline, but those will wait.
Top comments (0)