Using request-based constraints to only accept JSON formats for endpoints | Development Simplified

I prefer doing things The Rails Waytm whenever possible. Oftentimes when you are working with web requests, your controllers expect to respond to specific content formats. Formats that are outside of this expected format are generally handled within the controller layer. There’s nothing wrong with this approach BUT we can do better here by using request-based constraints in the Routing layer.

A simple JSON endpoint

Here’s a typical controller setup where we want to accomplish the following things:

class UsersController < ApplicationController
  def index
    users = User.all

    render json: users, status: :ok
  end
end

So this endpoint will only ever care about JSON responses. What happens if a client requests HTML?

The request succeeds and returns the jsonified user relation above. What should have been a failed request actually succeeds. Certainly not ideal but what if we use respond_to with a specific format?

Using respond_to to enforce format

class UsersController < ApplicationController
  def index
    users = User.all

    respond_to do |format|
      format.json { render json: users, status: :ok }
    end
  end
end

The result here is more expected. The above raises a ActionController::UnknownFormat when we make an HTML request to an endpoint that expects only JSON. This makes sense because HTML isn’t a handled format and therefore the controller doesn’t know what to do.

We could stop here (and that would be a perfectly acceptable way of crafting the controller action) but we’ve added 2 lines of code plus two blocks for what the original render json: users does in one. So how can we have a simple controller while still enforcing format?

Request-based constraints for the routing layer

What is a Lambda?
A lambda is equivalent to an anonymous function in other languages. This means that you can specific logic within one without providing it a name.

By specifying the valid formats directly in our routes.rb file, we ensure that at the routing layer that using an unsupported format will respond with a failed status. This is accomplishing by using a lambda to specify valid request formats using the following syntax lambda { |request| request.format == :json }

So let’s adjust our routes.rb file to add the new constraint and revert our UsersController to use the original render json format:

# config/routes.rb
Rails.application.routes.draw do
  resources :users, only: :index, constraints: lambda { |request| request.format == :json }
end

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    users = User.all

    render json: users, status: :ok
  end
end

Now when we make HTML requests to the endpoint the we recieve a routing error that looks like: ActionController::RoutingError: No route matches [GET] "/users". Basically this is saying that the above route doesn’t even exist which is true; the HTML version of the above route isn’t defined.

In addition to using the simpler render syntax in the controller we no longer need to handle invalid request formats directly inside the controller.

Want to get even fancier? We can use the shorthand “stabby” lambda syntax to achieve the same results with:

# config/routes.rb
Rails.application.routes.draw do
  resources :users, only: :index, constraints: -> request { request.format == :json }
end

So, now you’re thinking, “This is great and all, but I have 200 other json only endpoints. Am I going to need to repeat this pattern for each of them?”. Of course not! Rails doesn’t leave you hanging on this.

Advanced Constraints

For cases where we want to apply the same constraint to several routes, we can use a dedicated class. This class must respond to the #matches? message. With this we have ourselves a highly reusable format constraint.

For the below example I’ve added a couple extra routes for demonstration purposes.

# config/initializers/routes/format_constraints.rb
module Routes
  class FormatConstraints
    attr_reader :formats

    def initialize(formats)
      # This coerces formats into an array
      @formats = Array(formats)
    end

    def matches?(request)
      # This checks to see the request format matches the array
      # Useful for multi formats like Routes::FormatConstraints.new([:html, :json])
      formats.include?(request.format.symbol)
    end
  end
end

# config/routes.rb
Rails.application.routes.draw do
  resources :users, only: :index, constraints: Routes::FormatConstraints.new(:json)
  resources :posts, constraints: Routes::FormatConstraints.new(:json)
  resources :comments, constraints: Routes::FormatConstraints.new(:json)
end

We’re still repeating ourselves a bit with the above example. Luckily, the constraints syntax also has a block form which makes it even easier to group your routes by JSON only endpoints.

# config/routes.rb
Rails.application.routes.draw do
  constraints Routes::FormatConstraints.new(:json) do
    resources :users, only: :index
    resources :posts
    resources :comments
  end
end

I’ve also built the above to allow for arrays of formats to match against. This is useful for grouping routes that have multiple formats.

# config/routes.rb
Rails.application.routes.draw do
  constraints Routes::FormatConstraints.new(:json) do
    resources :users, only: :index
    resources :posts
    resources :comments
  end

  # This allows requests from html, json, and csv to the 
  # tags resource below
  constraints Routes::FormatConstraints.new([:html, :json, :csv]) do
    resources :tags
  end
end

Now we’re grouping all of our JSON routes behind a formatting constraint. Nice!

Acknowledgments

Steve Grossi for the idea of using a dedicated constraint class
Dave Jones for pointing me at the concept wrapping routes within a constraint block.

Wrapping Up

This is just one type of routing constraint that Rails allows for. If you’d like to learn more about request-based constraints here’s the documentation on the subject.

What did you think about this method? Does it make controller code cleaner at the price of hiding formatting logic? Tell me about it below.

Join the conversation

comments powered by Disqus