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.

Serializer Associations

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.

Serializer Custom Methods

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.

Custom Serializers

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

Resources:

18