現状のSwaggerスキーマとシリアライザの改善を行なった話

## はじめに

Railsで運用されているAPIサーバーのシリアライザとSwaggerスキーマをリファクタリングした話と、そこで当たった問題(余談)の紹介です。


## 現状の状態

社内のサービスはAPIサーバーとしてRailsを用いて運用されているのですが、長く運用されているため、手動で作成されるSwaggerスキーマとそれに対応したシリアライザが無秩序な状態にあり、新規APIの実装や修正が入るたびに各人の裁量でスキーマおよびシリアライザを使用・作成していて、運用しづらくなっていました。また、ActiveModel::Serializerなどは使っていなく、独自で実装を行なっています。

実際にどのような構成になっていたかの例を大雑把に。

### 例

例えば、以下のようなモデルがあったとして

```Ruby
class Member < ActiveRecord::Migration
  def change
    create_table :members do |t|
      t.string      :user_name
      t.string      :nick_name
      t.string      :role
      t.string      :badge
      t.string      :image
      t.integer     :sex
      t.string      :status
      t.string      :code
      t.boolean     :is_displayed
      t.timestamps null: false
    end
  end
end
```

上のようなモデルがあったとして、このメンバー情報を表示したいフロントの画面が以下のように３パターンあった場合、

- メンバー一覧画面
  - 表示するのはユーザー名とニックネームとステータスのみ
- メンバー詳細画面
  - 表示するのはメンバー情報一通り、だが必要ないものもある
- メンバー詳細と、そのメンバーが執筆した記事を表示する画面
  - MemberモデルのアソシエーションにArticleモデルがあったとして、それを表示する画面

何も考えず用途に合わせてシリアライザを実装すると以下のようになるかと思います（実際になっていました）
例がだいぶ微妙ですが、許してください


**メンバー一覧画面のシリアライザ**

表示するのはユーザー名とニックネームのみであり、それ以外は使わないので削ります。

```ruby:member_small_serializer.rb
class MemberSmallSerializer < Serializer
  attribute :id, :integer
  attribute :user_name, :string
  attribute :nick_name, :string
  attribute :status, :string

  def self.serialize(member)
    new(
      id: member.id,
      user_name: member.user_name,
      nick_name: member.nick_name,
      status: member.status,
    )
  end
end
```

**メンバー詳細画面のシリアライザ**

Memberモデルのほとんどの情報を返しますが、必要ないものもあるので、それは削ります。

```ruby:member_serializer.rb
class MemberSerializer < Serializer
  attribute :id, :integer
  attribute :user_name, :string
  attribute :nick_name, :string
  attribute :role, :string
  attribute :image, :string
  attribute :sex, :integer

  def self.serialize(member)
    new(
      id: member.id,
      user_name: member.user_name,
      nick_name: member.nick_name,
      role: member.role,
      image: member.image,
      sex: member.sex,
    )
  end
end
```

**メンバー詳細と、そのメンバーが執筆した記事を表示する画面のシリアライザ**

```ruby:member_large_serializer.rb
class MemberLargeSerializer < Serializer
  attribute :id, :integer
  attribute :user_name, :string
  attribute :nick_name, :string
  attribute :role, :string
  attribute :image, :string
  attribute :sex, :integer
  attribute :articles, array: :object

  def self.serialize(member)
    new(
      id: member.id,
      user_name: member.user_name,
      nick_name: member.nick_name,
      role: member.role,
      image: member.image,
      sex: member.sex,
      # articlesというアソシエーションがあるとする
      articles: member.articles.map { |article| ArticleSerializer.serialize(article) }
    )
  end
end
```

何も考えず１画面ずつ作っていくとこうなるかなーという例でした。

## これの問題点

メンバー情報を必要とする画面がもうこれ以上増えないとしたらこのままでもいいのですが、以下のような疑問・問題が残ります。

- member_smallシリアライザが必要か？
  - 冷静に考えて、member_smallシリアライザとmemberシリアライザはどちらもメンバー情報しか扱っていないため、メンバー情報取得の際に発行されるクエリは１つであり、返すカラムを絞るメリットがほとんどない
  - むしろ、新しくメンバー情報が必要な画面が増えた時、どちらのシリアライザを使うべきか選ぶ必要が出てくる
  - member_smallにはstatusがあるが、memberにはstatusがない、みたいな歯抜けの状態であり、よろしくない
- Memberテーブルに新しいカラムが追加になった場合、どうする？
  - 現状、メンバー情報を返すシリアライザは３つあり、新しいカラムが追加された場合、全てのシリアライザを修正する必要がある
- member_largeシリアライザってなんだ？
  - 名前だけ見ると一見汎用性のありそうなシリアライザだが、中身を見るとArticleアソシエーションを使用しており、他のシリアライザで安易に使うと余計なアソシエーションが走ってしまうのでNGという状態


と、トラップがいくつか生まれてしまいます。
今回の例ではカラム数が少なかったり、アソシエーションが少なかったり、メンバーを使ったシリアライザが3つだったりしますが、実際はもっとカオスです。

## どう改善したか

これらを解決すべく、まずMemberモデルの全ての情報を返す完全なmemberシリアライザを作ります。

```ruby:新_member_serializer.rb
class MemberSerializer < Serializer
  attribute :id, :integer
  attribute :user_name, :string
  attribute :nick_name, :string
  attribute :role, :string
  attribute :image, :string
  attribute :sex, :integer
  attribute :status, :string
  attribute :code, :string
  attribute :is_displayed, :boolean

  def self.serialize(member)
    new(
      id: member.id,
      user_name: member.user_name,
      nick_name: member.nick_name,
      role: member.role,
      image: member.image,
      sex: member.sex,
      status: member.status,
      code: member.code,
      is_displayed: member.is_displayed,
    )
  end
end
```

メンバー情報が必要な画面は必ずこのシリアライザを使うようすることで、smallや旧memberシリアライザのようなものは必要なくなりました

また、メンバーとそれに関連した情報が欲しい場合は、

```ruby:member_with_articles_serializer.rb
class MemberWithArticlesSerializer < MemberSerializer
  attribute :articles, object: :array

  def self.serialize(member)
    new(
      **super(member).attributes,
      articles: member.articles.map { |article| ArticleSerializer.serialize(article) }
    )
  end
end
```

このように、member_serializerを継承することで、新しくMemberテーブルにカラムが追加になり、memberシリアライザが修正されたとしても変更箇所は一つで済むようになりました。

今後、新たなテーブルを作成したときも、これと同じようにやっていくことで、シリアライザにある程度の秩序が生まれるかなと思います。

## スキーマではどう表現する？

これらを、Swaggerスキーマ(OpenAPI)でも表現するには、allOfという大変便利なものがあるので、それを使います。

```yml:member.yml
member:
  type: object
  additionalProperties: false
  nullable: true
  required:
    - id
    - user_name
    - nick_name
    - role
    - image
    - sex
    - status
    - code
    - is_displayed
  properties:
    id:
      type: string
    user_name:
      type: string
    nick_name:
      type: string
    role:
      type: string
    image:
      type: string
    sex:
      type: integer
    status:
      type: string
    code:
      type: string
    is_displayed:
      type: string
```

このようなメンバースキーマがあるとして、member_with_articles.ymlは以下のように定義することで、シリアライザと同じように実現できます。

```yml:member_with_articles.yml
member_with_articles:
  type: object
  additionalProperties: false
  nullable: true
  allOf:
    - "$ref": "#/components/schemas/member"
    - type: object
      required:
        - articles
      properties:
        articles:
          type: array
          items:
            "$ref": "#/components/schemas/article"
```

## 余談

余談で、滅多にないケースですが、上記のようなシリアライザでは型のオーバーライドも可能（rubyのコードなので当たり前ですが）で、例えば、memberシリアライザのstatusはstringですが、member_with_articlesシリアライザではstatusはobjectで返したい、というケースがあった場合、以下のように書いたりします。（どんなケースだよ、って思いますが実際ありました）

```ruby:member_with_articles_serializer.rb
class MemberWithArticlesSerializer < MemberSerializer
  attribute :status, :object
  attribute :articles, object: :array

  def self.serialize(member)
    new(
      **super(member).attributes,
      status: StatusSerializer(member.status)
      articles: member.articles.map { |article| ArticleSerializer.serialize(article) }
    )
  end
end
```

このように書くとstatusの型が上書きされ、stringではなくobjectとして返す、という表現になるわけですが、これをスキーマで表現すると少し問題があります。

スキーマで表現すると以下のようになります

```yml:member_with_articles.yml
member_with_articles:
  type: object
  additionalProperties: false
  nullable: true
  allOf:
    - "$ref": "#/components/schemas/member"
    - type: object
      required:
        - status
        - articles
      properties:
        status:
          "$ref": "#/components/schemas/status"
        articles:
          type: array
          items:
            "$ref": "#/components/schemas/article"
```

これ自体はエラーにならず、SwaggerUIでもちゃんと上書きされた型で表示されるのですが、openapi-parserというgemでバリデーションを行っている場合、この書き方ではバリデーションエラーになってしまいます。

理由としては、openapi-parserの実装上、allOfにて先に読み込まれるmemberスキーマのstatusの型(string)と、実際にstatusが返す値(object)を比較してしまい、エラーとなります。

なのでこれを回避したい場合、member_with_articlesスキーマではallOfを使わず、memberスキーマを直で記述する必要があります。

## さいごに

少し特殊な環境かつニッチな内容になってしまいましたが、こんな事例もあるんだな程度で見てもらえたら幸いです。