DEV Community

Hideaki Ishii
Hideaki Ishii

Posted on • Edited on

2 1

active_model_serializers with PORO (Plain-Old Ruby Object)

Recently, I worked on a Rails API project.

In the project, I fetched external data and made POROs which were compliant with active_model_serializers with the data.
Then our APIs returned the POROs serialized.

Today, I introduce the development flow a little bit.

Environment

  • Rails 6.0.0.beta3
  • active_model_serializers 0.10.9
  • factory_bot 5.0.2

Directory structure

  • app/models
    • POROs
  • app/serializers
    • Serializers
  • app/controllers
    • Endpoints

Model

As an example, let’s consider Image model which has a URL and size information.

active_model_serializers provides ActiveModelSerializers::Model for POROs like this, which is so easy to use.

If you need to deal with a more complicated case, you would be able to implement and use a model which is compliant with this specification instead.

# app/models/image.rb
class Image < ActiveModelSerializers::Model
  attributes :url, :size
end

# app/models/image/size.rb
class Image::Size < ActiveModelSerializers::Model
  attributes :width, :height
end
Enter fullscreen mode Exit fullscreen mode

Serializer

# app/serializers/image_serializer.rb
class ImageSerializer < ActiveModel::Serializer
  attributes :url, :type
  has_one :size
end

# app/serializers/image/size_serializer.rb
class Image::SizeSerializer < ActiveModel::Serializer
  attributes :width, :height
end
Enter fullscreen mode Exit fullscreen mode

Defining relations like has_one, we can use include option conveniently on endpoints.

For example, render json: image, include: '*' returns JSON including size and render json: image, include: '' returns JSON without size.

Controller

We can use serializers easily in controllers. All we have to do is create a model instance and pass it to render method.

Then active_model_serializers finds a suitable serializer for an instance given and serialize it, and the response returns.

# app/controllers/v1/images_controller.rb
module V1
  class ImagesController < ApplicationController
    def show
      render json: image, include: params[:include]
    end

    private

    def image
      @image ||= Image.new(image_attrs)
    end

    def image_attrs
      @image_attrs ||= fetch_data_somehow # Fetch external data
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing

When testing ActiveRecord models, we can use factory_bot and make the factories like:

FactoryBot.define do
  factory :user do
    name { Faker::Name.name }
  end
end
Enter fullscreen mode Exit fullscreen mode

Usage:

> user = FactoryBot.create(:user)
Enter fullscreen mode Exit fullscreen mode

But in this case, FactoryBot#create does not work well because there is no store (in addition, it’s not needed to store them).

Plus, ActiveModelSerializers::Model is based on ActiveModel, so the initializer requires a hash named attributes.

If we omit the argument attributes, attributes = {} will be given as the default, then serialization does not work well expectedly.

# spec/factories/images.rb
FactoryBot.define do
  factory :image do
    url { Faker::Internet.url }
    size { build(:image_size) }
  end
end

# spec/factories/image/sizes.rb
FactoryBot.define do
  factory :image_size, class: 'Image::Size' do
    width { rand(100..500) }
    height { rand(100..500) }
  end
end
Enter fullscreen mode Exit fullscreen mode

Usage:

> image = FactoryBot.create(:image)
=> NoMethodError: undefined method `save!`...

> image = FactoryBot.build(:image)
> image.attributes
=> {}

> image.to_json
=> "{}"
Enter fullscreen mode Exit fullscreen mode

factory_bot provides initialize_with to override initializers.

Also, it provides skip_create to skip creation.

# spec/factories/images.rb
FactoryBot.define do
  factory :image do
    skip_create
    initalize_with { new(attributes) }

    url { Faker::Internet.url }
    size { build(:image_size) }
  end
end

# spec/factories/image/sizes.rb
FactoryBot.define do
  factory :image_size, class: 'Image::Size' do
    skip_create
    initalize_with { new(attributes) }

    width { rand(100..500) }
    height { rand(100..500) }
  end
end
Enter fullscreen mode Exit fullscreen mode

Usage:

> image = FactoryBot.create(:image)
=> #<Image:...> The result is the same as one from `build`🙌

> image = FactoryBot.build(:image)
> image.attributes
=> { "url" => ..., "size" => ... }

> image.to_json
=> "{\"url\":...,\"size\":...}"
Enter fullscreen mode Exit fullscreen mode

To avoid writing initialize_with and skip_create many times, I eventually prepared a specific DSL like:

if defined?(FactoryBot)
  module FactoryBot
    module Syntax
      module Default
        class DSL
          # Custom DSL for ActiveModelSerializers::Model
          # Original: https://github.com/thoughtbot/factory_bot/blob/v5.0.2/lib/factory_bot/syntax/default.rb#L15-L26
          def serializers_model_factory(name, options = {}, &block)
            factory = Factory.new(name, options)
            proxy = FactoryBot::DefinitionProxy.new(factory.definition)
            if block_given?
              proxy.instance_eval do
                skip_create
                initialize_with { new(attributes) }
                instance_eval(&block)
              end
            end
            FactoryBot.register_factory(factory)

            proxy.child_factories.each do |(child_name, child_options, child_block)|
              parent_factory = child_options.delete(:parent) || name
              serializers_model_factory(child_name, child_options.merge(parent: parent_factory), &child_block)
            end
          end
        end
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The factory implementation turned out like:

# spec/factories/images.rb
FactoryBot.define do
  serializers_model_factory :image do
    url { Faker::Internet.url }
    size { build(:image_size) }
  end
end
Enter fullscreen mode Exit fullscreen mode

Then we can use it in specs easily like:

# spec/serializers/image_serializer_spec.rb
require 'rails_helper'

RSpec.describe ImageSerializer, type: :serializer do
  let(:resource) { ActiveModelSerializers::SerializableResource.new(model, options) }
  let(:model) { build(:image) }
  let(:options) { { include: '*' } }

  describe '#url' do
    subject { resource.serializable_hash[:url] }

    it { is_expected.to eq model.url }
  end
  ...
end
Enter fullscreen mode Exit fullscreen mode

Summary

We can use active_model_serializers without ActiveRecord easily.

References

Sentry image

See why 4M developers consider Sentry, “not bad.”

Fixing code doesn’t have to be the worst part of your day. Learn how Sentry can help.

Learn more

Top comments (0)