kaspth

Subscribe
Archives
July 6, 2024

Clear up your Rails controllers with before_action wrappers

What does clear code mean to you? To me, it's all about communication.

What does this say and how?
— and how is the system communicated in our specific case?

In our case, let's look at our Rails controllers.

Because there's a pattern I keep coming back to, but not many people know about it.

Today, let's learn how to define our own class method macros that wrap before_action.

Our case: a feature flag based Rails controller

Here's a feature flag Rails controller backed by Flipper:

class User::LikesController < ApplicationController
  def create
    return unless Flipper.enabled?(:like, Current.user)

    @like = Current.user.likes.create(like_params)

    redirect_to @like.article
  end

  def destroy
    return unless Flipper.enabled?(:like, Current.user)

    @like = Current.user.likes.find(params[:id]).tap(&:destroy)

    redirect_to @like.article
  end
end

Note: it's borrowed and lightly adapted from the excellent How to add feature flags to your Ruby on Rails applications.

Our controller here either creates or destroys a Like, but before that we require a feature flag with Flipper.enabled? in both actions.

This code is perfectly fine, it doesn't need to be more DRY.

However, if we change our perspective we can help establish a wider approach that's more oriented towards our system.

In other words, there is a potential pattern here that we could help establish for coworkers.

In codebases it can be tough to know if what you're doing is a good approach, but having a wrapping abstraction helps enable us to we're doing ok.

A holistic approach helps codify our institutional response to a problem.

Trialing out our abstraction

Let's explore our abstraction by extracting our feature flag check into a common before_action:

class User::LikesController < ApplicationController
  before_action { throw :abort unless Flipper.enabled?(:like, Current.user) }
end

We use throw :abort to halt the callback chain, a return isn't enough.

Now that we've got a central location let's trial out some potential APIs to see if it's worth going further.

If we can't get a clearer name that communicates more than the underlying code, then I'd stop here.

class User::LikesController < ApplicationController
  abort_without_feature :like

  must_have_feature :like

  requires_feature :like
end

This is just me writing different versions out and comparing the different naming and seeing which I like better. It's not a science.

Typically, for these before_action wrappers I tend to use requires_, require_, ensures_ or ensure_ as the prefix, but it's good to try alternatives.

Let's go with requires_feature and extract a class macro inline:

class User::LikesController < ApplicationController
  def self.requires_feature(name)
    before_action -> { throw :abort unless Flipper.enabled?(name, Current.user) }
  end
  requires_feature :like
end

Since the lambda here isn't a scope gate we can forward name directly.

Upgrading our default response

I'd like something else than throw :abort to be our response. In case a user accesses a feature they shouldn't I'd lean more towards head :bad_request.

There's more nuance here, but this'll have to do for this post.

def self.requires_feature(name)
  before_action { head :bad_request unless Flipper.enabled?(name, Current.user) }
end

Rounding out requires_feature

Whenver I do these before_action wrappers, I tend to also:

  1. Forward keyword arguments
  2. Provide defaults but allow overrides

How to forward keyword arguments

def self.requires_feature(name, **)
  before_action(-> { head :bad_request unless Flipper.enabled?(name, Current.user) }, **)
end

That's it. And now we can reuse before_action's options like only: and except:, like this:

requires_feature :like, only: :new
requires_feature :like, except: [:destroy, :update]

How to provide a default, but allow overrides

I'm not the most well-versed in Flipper, but I think a Current.account might be useful too, so we can do:

def self.requires_feature(name, from: :user, **)
  before_action(-> { head :bad_request unless Flipper.enabled?(name, Current.public_send(from)) }, **)
end

And now in case we have a feature that's validated from the current account, we can pass that:

requires_feature :like, from: :account

Where to put requires_feature

Now that we've verified our approach in one location, let's move it out.

# app/controllers/concerns/requires_feature.rb
module RequiresFeature
  def requires_feature(name, from: :user, **)
    before_action(-> { head :bad_request unless Flipper.enabled?(name, Current.public_send(from)) }, **)
  end
end

Pro-tip: since this only adds a class method, I prefer to just use extend and omit the self.. It also means we don't have to extend ActiveSupport::Concern.

And now we can extend our ApplicationController:

class ApplicationController < ActionController::Base
  extend RequiresFeature
end

Every controller can now call requires_feature. Congrats!

Resources

You can see another example of this pattern in my ActionController::StashedRedirects gem here.

There's also a Sudo authentication example in my gem above that uses this pattern.

Rails also wraps before_action's in more clarifying methods, here's two:

  • protect_from_forgery
  • skip_forgery_protection

What's next?

Hopefully this pattern is as useful to you as it's been to me.

Are there places you're now thinking of applying it? I'd be curious to hear about it.

Don't miss what's next. Subscribe to kaspth:
GitHub X
This email brought to you by Buttondown, the easiest way to start and grow your newsletter.