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)のモデルの属性、データベース上の対応するテーブルのカラムには、それぞれ微妙にズレが見られた。具体的には次のような問題があった:
- ARのモデルが持っている属性の一部だけがリソースの属性となる
- テーブルのカラム名とリソースのフィールド名/モデルの属性名が異なる
- 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
感想
- pros
- 既存の実装に影響を与えず、コントローラからもシンプルにリソースを生成できた
- cons
ATTRS_AND_METHODS
のようなマッピングを書かないといけない- 新しい層を導入するので設計意図をなんらかの手段で伝える必要がある