active_model_serializers with PORO (Plain-Old Ruby Object)
Hideaki Ishii
Posted on June 9, 2019
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
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
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
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
Usage:
> user = FactoryBot.create(:user)
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
Usage:
> image = FactoryBot.create(:image)
=> NoMethodError: undefined method `save!`...
> image = FactoryBot.build(:image)
> image.attributes
=> {}
> image.to_json
=> "{}"
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
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\":...}"
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
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
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
Summary
We can use active_model_serializers
without ActiveRecord
easily.
References
Posted on June 9, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.