active_model_serializers with PORO (Plain-Old Ruby Object)
Tags: ruby, rails, testing, activemodel
Published: 2019-06-09 13:22:07

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](https://github.com/rails-api/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](https://github.com/rails-api/active_model_serializers/tree/0-10-stable#what-does-a-serializable-resource-look-like), 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](https://github.com/rails-api/active_model_serializers/blob/0-10-stable/lib/active_model/serializer/lint.rb) instead.

```ruby
# 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

```ruby
# 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](https://github.com/rails-api/active_model_serializers/blob/0-10-stable/docs/general/adapters.md#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.

```ruby
# 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:

```ruby
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.

```ruby
# 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](https://github.com/thoughtbot/factory_bot/blob/master/GETTING_STARTED.md#custom-construction) to override initializers.

Also, it provides [skip_create](https://github.com/thoughtbot/factory_bot/blob/master/GETTING_STARTED.md#custom-methods-to-persist-objects) to skip creation.

```ruby
# 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:

```ruby
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:

```ruby
# 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:

```ruby
# 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

- https://github.com/rails-api/active_model_serializers/tree/0-10-stable#what-does-a-serializable-resource-look-like
- https://github.com/rails-api/active_model_serializers/blob/0-10-stable/docs/general/adapters.md#include-option
- https://github.com/thoughtbot/factory_bot/blob/master/GETTING_STARTED.md