Build a Highly Performant Api with Rails 6 and fast_jsonapi

Create a boilerplate Rails project

First generate a new rails api:

$ rails new rails-jsonapi \
  --database=postgresql \
  --skip-action-mailbox \
  --skip-action-text \
  --skip-spring -T \
  --skip-turbolinks \
  --api

$ cd rails-jsonapi

This will create a boilerplate Rails api project using postgresql as a database with a few things removed to keep it concise.

Next, generate the models and controllers:

rails g resource Author name:string --no-test-framework
rails g resource Article title:string body:text author:references --no-test-framework

Don't forget to add the has_many macro to the author model to complete the association.

# app/models/author.rb
class Author < ApplicationRecord
    has_many :articles
end

Add some seed data to get started.

bundle add faker
# db/seeds.rb
require 'faker'

Author.delete_all
Article.delete_all


10.times {
    Author.create( name: Faker::Book.unique.author)
}

50.times {
    Article.create({
        title: Faker::Book.title,
        body: Faker::Lorem.paragraphs(number: rand(5..7)),
        author: Author.limit(1).order("RANDOM()").first # sql random
    })
}

Setup the database, run migrations and generate seed data.

rails db:create db:migrate db:seed

Setup the Api endpoint and controller

Wrap the generated routes in a scope block to add /api as the base route for all routes nested with it. Don't worry about explicitly defining controller actions at this point.

# config/routes.rb
Rails.application.routes.draw do
  scope :api do
    resources :articles
    resources :authors
  end
end

Setup ArticleController and AuthorController with basic actions :index and :show, called by GET requests to /api/articles and /api/authors, respectively.

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
    before_action :find_article, only: :show
    def index
        @articles = Article.all
        render json: @articles
    end

    def show
        render json: @article
    end

    private
        def find_article
            @article = Article.find(params[:id])
        end
end

# app/controllers/author_controller.rb
class AuthorsController < ApplicationController
    before_action :find_author, only: :show
    def index
        @authors = Author.all
        render json: @authors
    end

    def show
        render json: @author
    end

    private
        def find_author
            @author = Author.find(params[:id])
        end
end

At this point we have a working api that response to requests with json! Any request sent to GET /articles However, we haven't implemented any of our associations into our response logic, so the response body for GET /articles does not include any author data.

[
  {
    "id": "1",
    "type": "article",
    "attributes": {
      "title" "Where the Red Fern Grows",
      "body": "...",
      "author_id": 2,
    }
  }
]  

We could solve this by changing the render lines to include the associations...

...
def index
  @articles = Article.all
  render json: @articles, include: [:author]
end

...but this can get cumbersome very quickly, and is not DRY at all. There are many ways to solve this issue, one of which being the active_model_serializers gem. If you just need to wrangle your json responses, this is a a viable option.

Enter Fast JSONapi

Fast JSONapi is a Ruby library created by the Netflix development team. It includes a serializer that implements the full https://jsonapi.org/ spec. This will introduce a bit more complexity in both the frontend and the backend, but the performance benefits can easily outweigh all of that, depending on your situation. You can learn more about fast_jsonapi's performance benchmarks https://github.com/Netflix/fast_jsonapi/blob/master/performance_methodology.md.

Add the fast_jsonapi gem to the project.

bundle add 'fast_jsonapi'

We can now use the serializer generator that is bundled with fast_jsonapi.

rails g serializer Article title body
rails g serializer Author name

This will create two files:

# app/serializers/article_serializer.rb
class ArticleSerializer
  include FastJsonapi::ObjectSerializer
  attributes :title, :body
end
# app/serializers/author_serializer.rb
class AuthorArticleSerializer
  include FastJsonapi::ObjectSerializer
  attributes :name
end

To keep it simple, we will only define the associations on the ArticleSerializer.

# app/serializers/article_serializer.rb
class ArticleSerializer
  include FastJsonapi::ObjectSerializer
  attributes :title, :body
  belongs_to :author
end

Implement the serializers in their respective controllers.

# app/controllers/authors_controllers.rb
class AuthorsController < ApplicationController
    before_action :find_author, only: :show
    def index
        @authors = Author.all
        options = { include: [:articles]}
        render json: AuthorSerializer.new(@authors, options).serializable_hash
    end

    def show
        options = { include: [:articles]}
        render json: AuthorSerializer(@author, options).serializable_hash
    end

    private
        def find_author
            @author = Author.find(params[:id])
        end
end
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
    before_action :find_article, only: :show
    def index
        @articles = Article.all
        options = { include: [:author]}

        render json: ArticleSerializer.new(
            @articles.preload(:author), 
            options
        ).serializable_hash
    end

    def show
        options = {:include => [:author]}
        render json: ArticleSerializer.new(@article, options).serializable_hash
    end

    private
        def find_article
            @article = Article.find(params[:id])
        end
end

Run rails s to start up the rails server.

If you're not already using a rest client such as Insomnia or Postman, get with the times! Make a GET request to localhost:3000/articles.

The response should look similar to this:

{
  "data": [
    {
      "id": "1",
      "type": "article",
      "attributes": ...,
      "relationships": {
        "author": {
          "data": {
            "id": "9",
            "type": "author"
          }
        }
      }
    },
    {
      "id": "2",
      "type": "article",
      "attributes": ...,
      "relationships": {
        "author": {
          "data": {
            "id": "3",
            "type": "author"
          }
        }
      }
    },
    {
      "id": "3",
      "type": "article",
      "attributes": ...
      "relationships": {
        "author": {
          "data": {
            "id": "3",
            "type": "author"
          }
        }
      }
    }
  ],
  "included": [
    {
      "id": "9",
      "type": "author",
      "attributes": {
        "name": "Tawna Denesik PhD"
      }
    },
    {
      "id": "3",
      "type": "author",
      "attributes": {
        "name": "Mrs. Carmela Herzog"
      }
    }
  ]
}