DEV Community

Jan Peterka
Jan Peterka

Posted on

Managing current user in Rails, our way - part 3

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

or, more likely, we even have just

# app/models/some_object.rb
class SomeObject < ApplicationRecord
  belongs_to :user
end
Enter fullscreen mode Exit fullscreen mode

, 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)
Enter fullscreen mode Exit fullscreen mode

, 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)