Jbuilder to AMS - A tested journey

12 min read
jbuilderamsjsontesting

Rails 5 is coming and ships with the --api option. This will generate a fresh but slimmed down Rails app whose job is to serve as a JSON API. With this option you'll get Active Model Serializers - AMS installed by default. This is a great library that is only getting better with all of the work that the AMS is doing on it.

Not everyone is creating a new Rails app though... most of us have existing ones, which may use RABL, Jbuilder, or a number of other options to generate JSON responses.

Knock knock, who's there? Legacy code.

Let's say we have a JSON API written in Jbuilder (or RABL) and we want to switch it over to using AMS... what's the first step? And what about after that? In this article I'll be walking us through converting our app to use AMS through a series of steps to make sure that the actual JSON response doesn't change.

Our App

Our app for this demo will consist of 3 models: League, Team, Player. We'll be looking at the teams#show endpoint which includes information about the team, the league it is apart of, along with a list of that team's players.

Our Team model looks like this:

class Team < ActiveRecord::Base
  belongs_to :league
  has_many :players
end

Our controller is pretty bare bones right now but it does the trick:

class TeamsController < ApplicationController
  def show
    @team = Team.find(params[:id])
  end
end

Now that we've got the model and controller out of the way, let's visit the path /teams/1 to take a look at the JSON that is produced:

{
  "id": 1,
  "name": "Toronto FC",
  "updated_at": "2015-11-27T19:01:49.846Z",
  "players": [
    {
      "id": 1,
      "name": "Sebastian Giovinco",
      "position": "Forward",
      "nickname": "Atomic Ant",
      "jersey_number": "10",
      "nationality": "Italian",
      "updated_at": "2015-11-27T19:01:49.852Z"
    },
    {
      "id": 2,
      "name": "Sebastian Giovinco",
      "position": "Forward",
      "nickname": "Atomic Ant",
      "jersey_number": "10",
      "nationality": "Italian",
      "updated_at": "2015-11-27T19:01:49.853Z"
    }
  ],
  "league": {
    "id": 1,
    "name": "MLS",
    "slug": "mls",
    "updated_at": "2015-11-27T19:01:49.835Z"
  }
}

Yes there are 2 Giovincos... it's a test app! Here is what our current Jbuilder view looks like to generate that response:

json.(@team, :id, :name, :updated_at)

json.league do
  json.id @team.league.id
  json.name @team.league.name
  json.slug @team.league.slug
  json.updated_at @team.league.updated_at
end

json.players @team.players do |player|
  json.(player, :id, :name, :nationality, :nickname, :jersey_number, :position, :updated_at)
end

Testing the endpoints

There will be some serious refactoring involved to migrate from Jbuilder to AMS.

Code refactoring is the process of restructuring existing computer code - changing the factoring - without changing its external behavior.

According to that definition that I took off of Wikipedia, refactoring involves changing the code, but not the external behavour. So what is the external behaviour in the case of an endpoint? It is the JSON that the endpoint returns. You could also argue that the speed in which it generates that JSON is also its external behaviour, but for today we'll be focusing on just making sure the JSON remains the same.

To do this we'll write some tests! There are many ways to ensure that our JSON responses remain the same, but this is a similar approach to how we've been testing our endpoints at theScore. What we'll do is hit the endpoint, record the result, and from then on test to ensure that what the API is returning is equal to the saved response we have.

Our test will create a few database objects, call the endpoint, and then verify the response:

require 'rails_helper'

RSpec.describe TeamsController, type: :request do
  it 'returns correct output for single team' do
    team = create(:team)
    create(:player, team: team)
    create(:player, team: team)

    verify_endpoint "/teams/#{team.id}"
  end
end

The verify_endpoint method isn't one that comes with rspec... it comes from a small module I wrote to help automate recording the endpoint responses and comparing pre-recorded responses with the current response.

module RequestHelpers
  FILTERED_FIELDS = ['updated_at'].freeze
  CAPTURE_BASE_PATH = 'spec/captures'.freeze

  def verify_endpoint(path)
    get path
    raw_body = response.body
    raw_expected = read_or_write_capture(path, raw_body)
    compare_response(raw_body, raw_expected)
  end

  private

  def read_or_write_capture(path, raw_body)
    file_name = "#{path.parameterize}.json"
    begin
      read_capture(file_name)
    rescue Errno::ENOENT
      write_capture(file_name, raw_body)
    end
  end

  def read_capture(file_name)
    File.read(File.join(CAPTURE_BASE_PATH, file_name))
  end

  def write_capture(file_name, raw_body)
    parsed_body = JSON.parse(raw_body)
    JSON.pretty_generate(parsed_body).tap do |pretty_json|
      File.write(File.join(CAPTURE_BASE_PATH, file_name), pretty_json)
    end
  end

  def compare_response(raw_body, raw_expected)
    parsed_body = filter(JSON.parse(raw_body))
    parsed_expected = filter(JSON.parse(raw_expected))
    expect(parsed_body).to eq(parsed_expected)
  end

  # Remove filtered fields and order keys to make comparisons easier
  def filter(hash)
    hash.keys.sort.each_with_object({}) do |key, hsh|
      value = hash[key]

      hsh[key] = if value.is_a?(Hash)
        filter(value)
      elsif value.is_a?(Array)
        value.map do |elem|
          filter(elem)
        end
      elsif FILTERED_FIELDS.include?(key)
        :filtered
      else
        value
      end
    end
  end
end

The most complicated method here is the filter method, whose job is basically to filter out fields which will end up changing from test to test, like the updated_at field.

Just to make sure I'm explaining all the code in this example, I'm using FactoryGirl to generate the data used in this test. The factories look like this:

FactoryGirl.define do
  factory :league do
    name 'MLS'
    slug 'mls'
  end
end

FactoryGirl.define do
  factory :player do
    name 'Sebastian Giovinco'
    nickname 'Atomic Ant'
    jersey_number '10'
    nationality 'Italian'
    position 'Forward'
    team
  end
end

FactoryGirl.define do
  factory :team do
    name 'Toronto FC'
    league
  end
end

Time for AMS

Now that we can verify the responses that our endpoints give, we can create AMS classes. Here are the 3 serializers I have, which should produce the same JSON result.

class LeagueSerializer < ActiveModel::Serializer
  attributes :id,
    :name,
    :updated_at
end

class PlayerSerializer < ActiveModel::Serializer
  attributes :id,
    :name,
    :position,
    :nickname,
    :jersey_number,
    :nationality,
    :updated_at
end

class TeamSerializer < ActiveModel::Serializer
  attributes :id,
    :name,
    :updated_at
  has_many :players
  belongs_to :league
end

We'll have to make a small change to the controller so that it uses the AMS classes instead of attempting to find a view.

class TeamsController < ApplicationController
  def show
    @team = Team.find(params[:id])
    render json: @team, serializer: TeamSerializer
  end
end

Now it's time to run the test bundle exec rspec, and to verify that they continue to pass after making our change. It's easy to forget a field... so let's leave one out on purpose just to make sure that the tests are working as expected. Plus there is something quite satisfying about making the test turn from red to green.

 -"league" => {"id"=>1, "name"=>"MLS", "slug"=>"mls", "updated_at"=>:filtered},
 +"league" => {"id"=>1, "name"=>"MLS", "updated_at"=>:filtered},

We can see that we forgot the slug field on the league... so we'll add that to the LeagueSerializer and now the test is passing again.

Preparing for failure

We now basically have 2 working ways to generate the JSON in our API... we will eventually get rid of the Jbuilder code but for now let's leave it. If AMS code gives us problems that we didn't plan for (performance for example), all it takes is changing our controller to switch back to the previous version while we work out the kinks. After it's been running in production for a few days and you're sure there are no issues, feel free to go back and remove the Jbuilder view that is no longer needed.

Concluding thoughts

In this post we changed our code from using Jbuilder to AMS, but that wasn't really the main point. The main point I wanted to get across was both how to test your JSON endpoints, but also how to approach code refactoring in general. Without tests, how do you know the code you refactored still works as expected?

You can get the entire source code used in this article at https://github.com/leighhalliday/soccer_app