Duetcode

Integrate activemodel serializers

11 July 2020

Welcome to the fourth chapter of the API-only rails course. In this part, we will integrate the activemodel serializers to our project. If you didn’t read the previous chapters of the course, you can check all content from the course page.

Serialize ruby objects without any external library

In the previous chapter, we have created the first version of the user creation endpoint. If we send a new request to create a user, the API response looks as follows.

{
  "id": 1,
  "email": "user@duetcode.io",
  "created_at": "2020-06-13T11:58:54.203Z",
  "updated_at": "2020-06-13T11:58:54.203Z"
}

By default, ruby on rails adds all columns of the model to the API response when we render a resource with JSON format. But let’s imagine we don’t want to expose updated_at value from the API endpoint for the created user. The simplest way of doing it without using any library is overriding the as_json method in the model, which is used by rails internally while serializing ruby objects. Let’s modify our User model to see it in action.

app/models/user.rb
# frozen_string_literal: true

class User < ApplicationRecord
  # ...

  def as_json(options)
    super({ only: %i[id email created_at] }.merge(options))
  end
end

Inside of the as_json method, we defined the fields we want to see in the response payload and called super method to get standard behavior from the ActiveRecord::Base with specified fields. (We also merged with options parameter to keep other options like status code.) If we send another request to create a new user now:

curl --location --request POST 'http://localhost:3000/api/v1/users' \
--header 'Content-Type: application/json' \
--data-raw '{ 
  "user" : {
    "email": "example@duetcode.io",
    "password": "samplepassword"
  }
}'

We will get an API response without an updated_at field.

{
    "id": 3,
    "email": "example@duetcode.io",
    "created_at": "2020-07-07T10:38:13.248Z"
  }

But as you can imagine, we would like to manipulate API payloads that will have much more complexity than the previous example. We will use nested associations with different data structures, and it will become hard to maintain with overriding the as_json method.

Introduce ‘active_model_serializers’ gem

To solve the problem we mentioned in a structured and straightforward way, we can use other libraries to manipulate the JSON structure of the API responses. We have different options like fast_jsonapi and jbuilder for serializing ruby objects. However, I would like to use active_model_serializers gem because it’s mature enough to have all functionality needed for serializers. But instead of using the latest version of the activemodel serializer (0.10.10), I will use version 0.8.4 because of performance reasons.

Let’s add active_model_serializers to the Gemfile and run bundle install command.

Gemfile
# ...

# Generate JSON in an object-oriented and convention-driven manner
gem 'active_model_serializers', '~> 0.8.4'

# ...

Then let’s create app/serializers/user_serializer.rb file and add id, email, created_at, and updated_at fields to the serializer.

app/serializers/user_serializer.rb
# frozen_string_literal: true

class UserSerializer < ActiveModel::Serializer
  attributes :id, :email, :created_at, :updated_at
end

So if we send this request with curl, we will create a new user and see the user’s payload as we specified in the user serializer. (Don’t forget to remove the as_json method we defined at the beginning of the chapter for demonstration purposes.)

curl --location --request POST 'http://localhost:3000/api/v1/users' \
--header 'Content-Type: application/json' \
--data-raw '{ 
  "user" : {
    "email": "sample@mail.com",
    "password": "samplepassword"
  }
}'

With the new serializer, the API response should be as following.

{
  "user": {
    "id": 5,
    "email": "sample@mail.com",
    "created_at": "2020-07-07T13:46:41.496Z",
    "updated_at": "2020-07-07T13:46:41.496Z"
  }
}

But how activemodel serializer knows which serializer class to use? It basically looks for the resource class name and searches for the serializer with the same name. For our use case, it looks for UserSerializer class. We also have an option to pass serializer name explicitly like render json: @user, serializer: UserSerializer in the controllers.

As you can see, activemodel serializers automatically adds the serializer name as a root key. Still, keep in mind that when we introduce the renderer module in the next chapters, we will refactor the root key part, and our API responses will have a general abstraction with two root fields as data and meta as follows.

{
  "data": {
    "id": 5,
    "email": "sample@mail.com",
    "created_at": "2020-07-07T13:46:41.496Z",
    "updated_at": "2020-07-07T13:46:41.496Z"
  },
  "meta": {
    "resource": "User",
    "count": 1 
  }
}

We completed the activemodel serializer integration, and it’s working as expected. But I realized that I would like to use id, created_at, and updated_at fields for all of my serializers. To do that, I can create a BaseSerializer (similar to BaseController for API controllers) and extend other serializers from the base serializer.

app/serializers/base_serializer.rb
# frozen_string_literal: true

class BaseSerializer < ActiveModel::Serializer
  attributes :id, :created_at, :updated_at
end

So we can refactor UserSerializer with removing id, created_at and updated_at fields and extending it from BaseSerializer.

app/serializers/user_serializer.rb
# frozen_string_literal: true

class UserSerializer < BaseSerializer
  attributes :email
end

We successfully completed the integration of base serializer and used the user serializer inherited from base serializer.

Write tests for the activemodel serializers

We can complete our serializer implementation with writing tests for both base and user serializers. Let’s start with building spec/factories/users.rb file since we will use the user factory to test serializers. The user model has presence validation for both email and password fields so that we can add those fields to the factory file.

spec/factories/users.rb
# frozen_string_literal: true

FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "user_#{n}@duetcode.io" }
    password { 'samplepassword' }
  end
end

We filled the password with the static value, but we used sequence for the email because we want to differentiate email addresses since they have to be unique, and we might create more than one user in the same spec. You can have more information about sequences from FactoryBot’s github page.

Now we can start writing the base serializer test.

spec/serializers/base_serializer_spec.rb
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe BaseSerializer, type: :serializer do
  let(:resource) { FactoryBot.create(:user) }
  let(:serialized_resource) { described_class.new(resource).as_json }

  subject { serialized_resource[:base] }

  it 'has an ID that matches with resource ID' do
    expect(subject[:id]).to eq(resource.id)
  end

  it 'has a created date time of the resource' do
    expect(subject[:created_at]).to eq(resource.created_at)
  end

  it 'has an updated date time of the resource' do
    expect(subject[:updated_at]).to eq(resource.updated_at)
  end
end

We have created a resource (we used User because we only have the user model at the moment) and checked whether its id, created_at, and updated_at values matched with the serialized resource.

We can also use the same approach while testing user serializer.

spec/serializers/user_serializer_spec.rb
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe UserSerializer, type: :serializer do
  let(:user) { FactoryBot.build(:user, email: 'user@duetcode.io') }
  let(:serialized_user) { described_class.new(user).as_json }

  subject { serialized_user[:user] }

  it 'has an email that matches with the user email' do
    expect(subject[:email]).to eq('user@duetcode.io')
  end
end

We also need to refactor creating a new user test case inside of spec/requests/api/v1/users_spec.rb file since active model serializers added the user as a root key.

spec/requests/api/v1/users_spec.rb
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe 'Api::V1::Users', type: :request do
  describe 'POST /api/v1/users' do
    # ...

    it 'creates a new user' do
      post api_v1_users_path, params: { user: user_params }
      expected_body = { 'email' => 'user@duetcode.io' }

      expect(response).to have_http_status(:created)
      expect(load_body(response)['user']).to include(expected_body)
    end

    # ...
  end
end

And now we can run bundle exec rspec command to see all tests are green.

~/code/bookmarker (master) $ bundle exec rspec
........

Finished in 0.08916 seconds (files took 0.76624 seconds to load)
8 examples, 0 failures

Lastly, I want to mention that we can use create and build methods directly instead of using them as FactoryBot.create(...) and FactoryBot.build(...). To do that, we need to modify spec/rails_helper.rb file as following.

spec/rails_helper.rb
# frozen_string_literal: true

# ...
require 'factory_bot_rails'

RSpec.configure do |config|
  config.include FactoryBot::Syntax::Methods

  # ...
end

And now, let’s remove the FactoryBot from create and build methods.

spec/serializers/base_serializer_spec.rb
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe BaseSerializer, type: :serializer do
  let(:resource) { create(:user) }
  let(:serialized_resource) { described_class.new(resource).as_json }

  subject { serialized_resource[:base] }

  it 'has an ID that matches with resource ID' do
    expect(subject[:id]).to eq(resource.id)
  end

  it 'has a created date time of the resource' do
    expect(subject[:created_at]).to eq(resource.created_at)
  end

  it 'has an updated date time of the resource' do
    expect(subject[:updated_at]).to eq(resource.updated_at)
  end
end
spec/serializers/user_serializer_spec.rb
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe UserSerializer, type: :serializer do
  let(:user) { build(:user, email: 'user@duetcode.io') }
  let(:serialized_user) { described_class.new(user).as_json }

  subject { serialized_user[:user] }

  it 'has an email that matches with the user email' do
    expect(subject[:email]).to eq('user@duetcode.io')
  end
end

Now, we will run the rspec command again to make sure everything works as they were before.

~/code/bookmarker (master) $ bundle exec rspec
........

Finished in 0.0915 seconds (files took 0.79969 seconds to load)
8 examples, 0 failures

Summary

In this chapter, we integrate the activemodel serializer into the project. We also created the base and user serializers with corresponding spec files. You can find the source code of bookmarker application on github. In the next chapter, we will introduce the renderer module for API controllers. You can also find the previous chapter here.

Thanks for reading! I would love to hear your feedback about the chapter and the course in general. You can contact me by email or from twitter.