ActiveModel::SerializersでSTIのtypeに応じてSerializerを切り替える

こんにちは 田村 です。

STI を使用して複数の type のモデルデータがあり、これを 1 つの API エンドポイントで混ぜて返したいとします。
このとき、 STI の type に応じて serializer を切り替えたくなります。
今回は、その方法を紹介します。

STI についてはこちらを参照してください。

困ったこと

以下のようなクラス階層を STI で実現しているとします。

SimplePosts は titlebody を、 QAPosts は questionanswer のフィールドを持ちます。 共通して posted_at のフィールドを持ちます。

/api/v1/posts という API エンドポイントを定義して、下記のような JSON を返したいとします。

{
    "posts": [
        {
            "id": 3,
            "type": "QAPost",
            "posted_at": "2022-11-02T17:00:00.000Z",
            "question": "質問2",
            "answer": "質問2の回答です。"
        },
        {
            "id": 4,
            "type": "SimplePost",
            "posted_at": "2022-11-02T15:00:00.000Z",
            "title": "投稿2",
            "body": "Consectetur adipiscing elit."
        },
        {
            "id": 2,
            "type": "QAPost",
            "posted_at": "2022-11-01T16:00:00.000Z",
            "question": "質問1",
            "answer": "質問1の回答です。"
        },
        {
            "id": 1,
            "type": "SimplePost",
            "posted_at": "2022-10-31T15:00:00.000Z",
            "title": "投稿1",
            "body": "Lorem ipsum dolor sit amet."
        }
    ]
}

Posts の type が SimplePostQAPost かによって、 JSON のキーが異なります。

JSON の出力を ActiveModel::Serializers で行っている場合、 Posts の type に応じて、 Serializer を切り替える必要が出てきます。

解決方法

先にコードを示します。

app/serializers/post_serializer.rb

class PostSerializer < ActiveModel::Serializer
  attributes :id, :type, :posted_at

  def attributes(requested_attrs = nil, reload = false)
    @attributes = super(requested_attrs, reload)

    case object
    when SimplePost
      @attributes.merge(SimplePostSerializer.new(object).attributes(nil, reload))
    when QAPost
      @attributes.merge(QAPostSerializer.new(object).attributes(nil, reload))
    end
  end

  def json_key
    'posts'
  end
end

app/serializers/simple_post_serializer.rb

class SimplePostSerializer < ActiveModel::Serializer
  attributes :title, :body
end

app/serializers/qa_post_serializer.rb

class QAPostSerializer < ActiveModel::Serializer
  attributes :question, :answer
end

app/controllers/api/v1/posts_controller.rb

module Api
  module V1
    class PostsController < ApplicationController
      def index
        posts = Post.all.order(posted_at: :desc)

        render json: posts, each_serializer: PostSerializer
      end
    end
  end
end

PostSerializer で attributes メソッドをオーバーライドします。各データは object で取れるため、 case で class を判定し、 SimplePostSerializer.new(object).attributes(nil, reload)QAPostSerializer.new(object).attributes(nil, reload) で、 attributes を生成し @attributes にマージしています。

参考: https://github.com/rails-api/active_model_serializers/blob/0fbe0fad0dec9368e9335b6280a46ca13442727e/lib/active_model/serializer.rb#L334-L343

json_key メソッドをオーバーライドしているのは、 json の toplevel object で posts と表示させるためです。 これを定義しないと、下記のように qa_posts と、最初に Serializer で処理した type が key としてセットされてしまいます。

{
    "qa_posts": [
        {
            "id": 3,
            "type": "QAPost",
            "posted_at": "2022-11-02T17:00:00.000Z",
            "question": "質問2",
            "answer": "質問2の回答です。"
        },
        ...
    ]
}

なお、 jsonapi_include_toplevel_objectfalse にしている場合は、 json_key メソッドのオーバーライドは不要です。

参考: https://github.com/rails-api/active_model_serializers/blob/0fbe0fad0dec9368e9335b6280a46ca13442727e/lib/active_model/serializer.rb#L384-L392