Kōhei Yamamoto

リソースとActive Recordのモデルのあいだの差異を吸収するクラスを作る

Web APIのリソースとバックエンドで扱うモデル(特にActive Recordのモデル)に歴史的な事情で差異があり、単純にモデルからリソースへと変換できないとき、それらの差異を吸収するクラスを作って対応することがあったのでメモを残しておきます。


問題

あるWeb APIに、親リソース1authorが1:Nで持つ子リソースbookの一覧だけを返すエンドポイントGET /booksを追加しようとしていた2。追加するエンドポイントが返すリソースの例を示す:

{
"books": [
{
"id": 1
"author_id": 1,
"title": "Bar",
"publisher": "Baz, Inc.",
"released_at": "2020-01-01"
},
// ...
]
}

なお、従来から、親リソースGET /authorsを取得するときにはbooksも取得はできていたとする。また、既存のAPIリソースはActive Model Serializer (AMS)で生成しているとする。

このエンドポイントで返すリソースが持つフィールド、リソースに対応するActive Record (AR)のモデルの属性、データベース上の対応するテーブルのカラムには、それぞれ微妙にズレが見られた。具体的には次のような問題があった:

  1. ARのモデルが持っている属性の一部だけがリソースの属性となる
  2. テーブルのカラム名とリソースのフィールド名/モデルの属性名が異なる
  3. APIリソースのフィールド名とARモデルのリレーション名が被っている

まず、ARのモデルが持っている属性、すなわち対応するテーブルが持っているすべてのカラムのうち一部だけがリソースの属性となっていた。この場合、リソースを表すJSONに変換するときにフィルタする必要がある。

create_table :books do |t|
t.column :title, :string
t.column :release_date, :date
t.column :memo, :string # リソースには含めない
# ...
end

また、テーブルのカラム名をよりわかりやすくするために、その後に作られたモデルやAMSのシリアライザでは改名された属性名を使っているケースがあった。

class Book < ApplicationRecord
alias_attribute :title, :name
end
class BookSerializer < ActiveModel::Serializer
def released_at
object.release_date
end
end

さらに、特殊な事例だが、ARのモデルではリレーションとして定義されている名前が、AMSで生成されるリソースではスカラーな値として提供されているケースがあった。

class Book < ApplicationRecord
belongs_to :publisher
end
class Publisher < ApplicationRecord
has_many :books
end
class BookSerializer < ActiveModel::Serializer
attributes :publisher
def publisher
object.publisher.name
end
end

後述するが、このケースでAMSを使わないようにする場合、リソース用に加工したフィールドの値を作る必要がある。しかし、今回はリレーションとしてそのフィールドと同名のメソッドが存在するので、メソッド名に気をつけないと、そのリレーションを使っている他のコードが壊れてしまってうまくいかない。

今回は、既存のコードをできるだけ変えずにこれらの問題に対処しながら、エンドポイントGET /booksを追加したいとする。

リソースとモデルの間の差異を吸収する層を導入する

問題を解決するために、リソースとモデルの間の差異を吸収する層を導入する。今回はAMSを使わず実現した3。POROにActive Modelを組み合わせて使う。

次のようにリソースのフィールドとモデルの属性のマッピングを持たせたクラスを書く。リソースだけで使うメソッドはモデルに書く。

class Book < ApplicationRecord
# ...
concerning :Api do
def publisher_name
publisher.name
end
end
end
class Book::ListResource
include ActiveModel::Model
attr_accesor :books
ATTRS_AND_METHODS = {
id: :id,
author_id: :author_id,
title: :title,
publisher: :publisher_name,
released_at: :release_date
}
def build
books.map do |book|
ATTRS_AND_METHODS.map { |attr, method|
[attr, book.send(method)]
}.to_h
end
end
end

次のように使う。

# コントローラ内での利用例
@books = Book::ListResource.new(
books: Book.order(:id).limit(20)
)
# JBuiderのテンプレート
json.books @books

感想

脚注

  1. リソースは「APIが返すデータの構造」ぐらいの意味合いとする

  2. これはあくまでも例

  3. 内部事情