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.
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.
# 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.
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.
# ...
# 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.
# 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.
# 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
.
# 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.
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.
# 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.
# 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.
# 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.
# 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.
# 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.
# 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
# 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
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.