18
Cerealizing with Serializers
If you are reading this, you might, like me, be relatively new to Ruby on Rails, and are still getting familiar with some of the features it has to offer. Personally, when I first heard about serializers, they seemed a bit redundant. We are already handling what we are rendering in our controller, so why add an extra layer? While it may seem like extra work in a small application, as you expand your database, you will start to realize that sorting through your data in the controllers becomes increasingly difficult.
For now, let's just take a look at a smaller application to understand the basics. Let's say we've built out the basics for a database for cereal companies, and we want to show those cereals to users buying them online (for now, we won't worry about creating users, we will just focus on the cereal side). Right now, we aren't using serializers, we just have a controller and a model for Company, CEO, Charity, and of course, Cereal. For our purposes, a Company has_many
CEOs, Charities and Cereals.
We just have some simple models for now:
class Company < ApplicationRecord
has_many :ceos
has_many :charities
has_many :cereals
end
class Ceo < ApplicationRecord
belongs_to :company
end
class Charity < ApplicationRecord
belongs_to :company
end
class Cereal < ApplicationRecord
belongs_to :company
end
Alright, now let's take a look at our routes.rb
.
Rails.application.routes.draw do
resources :cereals
resources :companies
resources :charities
resources :ceos
end
Using resources
will automatically include all RESTful routes, which means we can define index
and show
in our cereal controller. (You should typically only include routes that you intend on using, but for now, we'll leave it as is)
class CerealsController < ApplicationController
def index
cereals = Cereal.all
render json: cereals
end
def show
cereal = Cereal.find(params[:id])
render json: cereal
end
end
Note: using a .find in the show route on its own is not best practice, because if an incorrect id is provided, there will be no rescue from the error. If you would like to know more about handling errors, check out the Rails documentation for info on error handling and validations. Since we are only focused on setting up serializers, we'll skip this for now.
Ok, so as it stands right now, let's take a look at what returns to us on a GET request to '/cereals'.
[
{
"id": 1,
"name": "Cheerios",
"company_id": 1,
"price": 3.49,
"ingredients": "*list of ingredients*",
"time_to_make": 1.3,
"transportation_schedule": "*list of schedule details*",
"expiration_date": "Jan 12, 2022",
"warehouse_location": "Cleveland",
"created_at": "2021-12-17T17:55:35.618Z",
"updated_at": "2021-12-17T17:55:35.618Z"
},
{
"id": 2,
"name": "Chex",
"company_id": 1,
"price": 3.99,
"ingredients": "*list of ingredients*",
"time_to_make": 0.9,
"transportation_schedule": "*list of schedule details*",
"expiration_date": "Jan 10, 2022",
"warehouse_location": "Des Moines",
"created_at": "2021-12-17T17:55:35.633Z",
"updated_at": "2021-12-17T17:55:35.633Z"
},
{
"id": 3,
"name": "Lucky Charms",
"company_id": 1,
"price": 4.49,
"ingredients": "*list of ingredients*",
"time_to_make": 1.5,
"transportation_schedule": "*list of schedule details*",
"expiration_date": "Jan 02, 2022",
"warehouse_location": "Minneapolis",
"created_at": "2021-12-17T17:55:35.648Z",
"updated_at": "2021-12-17T17:55:35.648Z"
}
]
It works! But that is a lot of data that we don't necessarily need. If the request is being made by a user that is hoping to buy a box of cereal, there is definitely some data that can be left out. When you are buying cereal, you don't need to know the id of that particular box of cereal, how long it took to make, the transportation schedule, the warehouse it is stored in, or when it was added or updated to the database. What we could do is specify what parts of the hash we want rendered in our controller, but we would have to repeat that process on any and every request we make as we continue to build out the application. What we can do instead is use serializers. To get started, we have to install the serializers gem.
bundle add active_model_serializers
This will add the gem to your gemfile and install it at the same time. Now we can create a serializer for cereal with rails g serializer cereal
. You will see that this automatically create a serializers folder in your application and add a cereal_serializer to it. I know what you may be thinking, "Generating serializers for all of my models sounds exhausting and time consuming!" And if you do it the way we just did, you'd be right. But, a bonus to the serializer gem is that if you install it at the beginning of your application, before creating any of your migrations, models, or controllers, running rails g resource cereal
will also create a serializer so that you don't have to. By default, it will even include any relationships and attributes that you added to your generation. Since we didn't do that this time around, this is what our serializer looks like right now:
class CerealSerializer < ActiveModel::Serializer
attributes :id
end
Because it only includes the attribute :id
, the only thing that will be rendered it the cereal id. Taking a look at our GET request again, we see:
[
{
"id": 1
},
{
"id": 2
},
{
"id": 3
}
]
Pretty cool, but still not the information we want to show our buyers. Looking at all of the data from the previous request, the realistic attributes we might want to include are name, company_id, price, ingredients, and expiration date. So let's do that.
class CerealSerializer < ActiveModel::Serializer
attributes :name, :company_id, :price, :ingredients,
:expiration_date
end
And see the new return results:
[
{
"name": "Cheerios",
"company_id": 1,
"price": 3.49,
"ingredients": "*list of ingredients*",
"expiration_date": "Jan 12, 2022"
},
{
"name": "Chex",
"company_id": 1,
"price": 3.99,
"ingredients": "*list of ingredients*",
"expiration_date": "Jan 10, 2022"
},
{
"name": "Lucky Charms",
"company_id": 1,
"price": 4.49,
"ingredients": "*list of ingredients*",
"expiration_date": "Jan 02, 2022"
}
]
Sweet! Now, without changing anything in our controller, let's look at the results of a Get request to a specific cereal id, triggering the show
route.
{
"name": "Cheerios",
"company_id": 1,
"price": 3.49,
"ingredients": "*list of ingredients*",
"expiration_date": "Jan 12, 2022"
}
As you can see, the specified attributes of the cereal serializer are being used in both routes from the cereal controller. This way, we can specify what attributes we want in a scope that will affect all of our routes.
But what about that company_id
that we are getting? That won't be very useful to a buyer. We want to be able to see the actual company name, not just the id.
Serializers have the ability to show associations through has_many
and belongs_to
, just like in our models. You may notice that if you generate a serializer using resource
, a model that belongs to another will have a serializer with a has_one
association instead of belongs_to
. These operate the same, but I find it easier and more consistent to use belongs_to
. Let's update our cereal serializer with a belongs_to
and see what we get. We also remove the company_id attribute, since we won't be needing that.
class CerealSerializer < ActiveModel::Serializer
attributes :name, :company_id, :price, :ingredients, :expiration_date
belongs_to :company
end
On a show route, this will return:
{
"name": "Cheerios",
"price": 3.49,
"ingredients": "*list of ingredients*",
"expiration_date": "Jan 12, 2022",
"company": {
"id": 1,
"name": "General Mills",
"employees": 40000,
"healthcare_plan": "*plan description*",
"revenue": 17000000000.0,
"created_at": "2021-12-17T19:05:02.950Z",
"updated_at": "2021-12-17T19:05:02.950Z"
}
}
Great! Now we have the company details from that association. But again, we're getting information that we don't need. There's a few ways we could handle this now.
The first way we can handle it is to create serializers for our other classes, and use them through our belongs_to
association. Let's create a serializer for company with rails g serializer company
, and edit the attributes it shows.
class CompanySerializer < ActiveModel::Serializer
attributes :name
end
Here's the result of requesting now:
{
"name": "Cheerios",
"price": 3.49,
"ingredients": "*list of ingredients*",
"expiration_date": "Jan 12, 2022",
"company": {
"name": "General Mills"
}
}
Sweet! We now have the company name associated with our cereal. There are a few problems with this solution, however. For one, as you can see, in order to access that company name, we have to read down two levels to the nested data. Not too ideal. Another is that this serializer will be filtering all of the company requests as well. So, a GET request to '/companies' will only give us:
[
{
"name": "General Mills"
},
{
"name": "Kellogg's"
},
{
"name": "Quaker Oats"
},
]
If all we are using this database for is to show the company names, this is fine, but we may want to be able to access different attributes of those parent models.
The second solution fixes some of those problems. We can create custom serializer methods and include them in the attributes. First we'll want to remove the belongs_to :company
from our cereal serializer.
class CerealSerializer < ActiveModel::Serializer
attributes :name, :price, :ingredients, :expiration_date,
:company_name
def company_name
object.company.name
end
end
We can define methods within our serializers to perform data handling, then all we need to do is add that method name to our attributes list. object
is a moniker for the object that is being passed to the serializer, similar to self
in class models. Here's what we get on this request:
{
"name": "Cheerios",
"price": 3.49,
"ingredients": "*list of ingredients*",
"expiration_date": "Jan 12, 2022",
"company_name": "General Mills"
}
Much cleaner! Using custom methods is a pretty slick way to include associated data when we only want certain attributes of that data. Now, as you can see, the company name is on the same level as all of our other data, so no need to dig into nested data.
To show another example of custom methods, I am now going to use the CEO and Charity models that were defined earlier, but never used. Let's say a company wanted to include some details of a charitable cause they are backing and a motto from their fearless CEO on the box they are selling. Obviously, in real life, these would just be mass-printed on the boxes during packing, but since we are working in a digital world, we need to find a way to render that information. Since both are children of Company, I will just be using Ceo.first
and Charity.first
in reference to each instance I want to use.
class CerealSerializer < ActiveModel::Serializer
attributes :name, :price, :ingredients, :expiration_date,
:company_name, :ceo_name, :ceo_motto, :charity_name,
:charity_cause
def company_name
object.company.name
end
def ceo_name
object.company.ceos.first.name
end
def ceo_motto
object.company.ceos.first.motto
end
def charity_name
object.company.charities.first.name
end
def charity_cause
object.company.charities.first.cause
end
end
And here's our render:
{
"name": "Cheerios",
"price": 3.49,
"ingredients": "*list of ingredients*",
"expiration_date": "Jan 12, 2022",
"company_name": "General Mills",
"ceo_name": "Jeff Harmening",
"ceo_motto": "Cereal is our passion.",
"charity_name": "General Mills Foundation",
"charity_cause": "Advancing regenerative agriculture"
}
The way in which you go about receiving data will differ based on how your database is set up and nested, but hopefully this gives you an idea of how to start that process.
"But wait," you might be thinking, "what if I don't want to show ALL of that data on all of my cereal requests?" Well, there is a simple solution to that problem: custom serializers. As of right now, if we were to make a GET request to all of our cereals, the index
route would return this:
[
{
"name": "Cheerios",
"price": 3.49,
"ingredients": "*list of ingredients*",
"expiration_date": "Jan 12, 2022",
"company_name": "General Mills",
"ceo_name": "Jeff Harmening",
"ceo_motto": "Cereal is our passion.",
"charity_name": "General Mills Foundation",
"charity_cause": "Advancing regenerative agriculture"
},
{
"name": "Chex",
"price": 3.99,
"ingredients": "*list of ingredients*",
"expiration_date": "Jan 10, 2022",
"company_name": "General Mills",
"ceo_name": "Jeff Harmening",
"ceo_motto": "Cereal is our passion.",
"charity_name": "General Mills Foundation",
"charity_cause": "Advancing regenerative agriculture"
},
{
"name": "Lucky Charms",
"price": 4.49,
"ingredients": "*list of ingredients*",
"expiration_date": "Jan 02, 2022",
"company_name": "General Mills",
"ceo_name": "Jeff Harmening",
"ceo_motto": "Cereal is our passion.",
"charity_name": "General Mills Foundation",
"charity_cause": "Advancing regenerative agriculture"
}
]
...
That's a lot of data. If a user is browsing through cereal options, they do not necessarily need to see all of that information right away. If we wanted to change the data being shown in this request to only return the cereal name, the first thing we might think to do is change our serializer. But then, our show
route would be missing all of that data we just worked so hard to get. What we can do is generate a new serializer. Let's call it CustomCerealSerializer
. To do so, we can run rails g serializer custom_cereal
. Now, let's just copy and paste all of our attributes and custom methods into the new serializer:
class CustomCerealSerializer < ActiveModel::Serializer
attributes :name, :price, :ingredients, :expiration_date,
:company_name, :ceo_name, :ceo_motto, :charity_name,
:charity_cause
def company_name
object.company.name
end
def ceo_name
object.company.ceos.first.name
end
def ceo_motto
object.company.ceos.first.motto
end
def charity_name
object.company.charities.first.name
end
def charity_cause
object.company.charities.first.cause
end
end
And set our original serializer to only include name:
class CerealSerializer < ActiveModel::Serializer
attributes :name
end
Now, let's see what the index
request will return:
[
{
"name": "Cheerios"
},
{
"name": "Chex"
},
{
"name": "Lucky Charms"
}
]
That looks like exactly what we wanted. But what happened to our other serializer? Right now, if we run a show
request, we will also only recieve the name. What we can do is specify what serializer we want to use in our cereals_controller
.
class CerealsController < ApplicationController
def index
cereals = Cereal.all
render json: cereals, status: :ok
end
def show
cereal = Cereal.find(params[:id])
render json: cereal, serializer: CustomCerealSerializer,
status: :ok
end
end
Because the index
route does not have a specified serializer, it will default to the CerealSerializer, which as we just saw, only has the attribute of name. By specifying our custom serializer on the show
route, our return will render those attributes and methods that we defined within it:
{
"name": "Cheerios",
"price": 3.49,
"ingredients": "*list of ingredients*",
"expiration_date": "Jan 12, 2022",
"company_name": "General Mills",
"ceo_name": "Jeff Harmening",
"ceo_motto": "Cereal is our passion.",
"charity_name": "General Mills Foundation",
"charity_cause": "Advancing regenerative agriculture"
}
Custom serializers and custom serializer methods are extremely useful in specifying what data you want to be returned, and when. Using has_many and belongs_to associations within your serializers can also be very useful for chaining serializers together. Just be sure that all of the data from the serializer being called in that association is pertinent to that render. You should ideally never have to change one model's serializer to adjust the data being returned in an association from another. If you ever find yourself appending loads of excludes or includes to the end of a return to get the data you want, creating a new custom serializer may not be a bad idea.
Important note: Throughout the examples above, we never specified the cereal_id in our attributes
. This was mainly to show the concept of including only information that would be useful to a user. However, in actual practice, you should typically include the id in your attributes, like so:
class SomeSerializer < ActiveModel::Serializer
attributes :id, :name, #etc...
end
Although a user might never need to see the cereal_id, in order to act upon that cereal instance, we need it's id, so including it in your attributes is always a good idea.
18