画像は『Rails: ActiveModelSerializersでAPIを作る』より引用
TL; DR
-
ActiveModelSerializers
インスタンス生成時に、key: value
で引数を渡すことができる -
ActiveModelSerializers
クラス側で、@instance_options
のハッシュから値を取り出すことができる
動作環境
- Ruby:2.6.1
- Rails:5.2.3
- ActiveModelSerializers:0.10.10
ActiveModelSerializersが0.10であれば他はそこまで気にしなても良いです
前提
簡易コード
以下が、今回説明するために使う簡易的なコードです。
ArticlesController
module Api
module V1
class ArticlesController < Api::ApplicationController
def index
articles = Article.order(created_at: :desc).page(params[:page])
serializable_resource = ActiveModelSerializers::SerializableResource.new(
articles,
includes: '**',
each_serializer: ArticleSerializer
)
json = { articles: serializable_resource.as_json }
render json: json
end
end
end
end
ArticleSerializer
class ArticleSerializer < ActiveModel::Serializer
attributes :id, :title, :content, :image_url
end
やりたいこと
ArticleSerializer内で、現在ログインしているユーザーを表すcurrent_user
のデータを使いたい場面があるとします。そのため、ArticleControllerからcurrent_user
の情報を渡してあげる必要があります。
今回は「記事をユーザーが読んだこと(=既読)」を表す中間テーブルUserArticle
の有無を確認するために、current_user
を引数に渡す必要があるとします。
少し調べたけどできなかったこと
-
scope
やoptioins
ハッシュを用いたら取り出せそう...で、できなかった - おそらくActiveModelSerializersのバージョンが異なるためだと思われる
調査している時に参考にさせていただいた記事。おかげさまで、ハッシュを使うんだろうなーというあたりはつきました。
解決法
@instance_options
でおk
bingind.pry
したらすぐに分かったのですが、@instance_options
内に引数のデータがハッシュ形式で格納されていることが判明しました。なので、このオプションから引数のハッシュを取り出せばやりたいことが実現できます。
$ rails c
$ serializable_resource
=> #<ActiveModelSerializers::SerializableResource:0x000055e581f4abe8
@adapter=
#<ActiveModelSerializers::Adapter::Attributes:0x00007fd6c4089b78
@instance_options={:include=>"**", :fieldset=>#<ActiveModel::Serializer::Fieldset:0x00007fd6c4089ab0 @fields={}, @raw_fields={}>},
@serializer=
#<ArticleSerializer:0x000055e57db81ce0
@attributes=
{:id=>1,
:title=>"test",
:content=>"test",
:image=>nil
~<中略>~
# ココ
@instance_options=
{:current_user=>
#<User:0x000055e57c1d5b20
id: 1,
ActiveModelSerializersのソースを見ると、以下の部分が該当します(最初から見ておけばよかった...)
attr_reader :serializer, :instance_options
def initialize(serializer, options = {})
@serializer = serializer
@instance_options = options
end
簡易コード(追記)
先ほどのコードに上記を反映すると、以下のようになります。
ArticlesController
module Api
module V1
class ArticlesController < Api::ApplicationController
def index
articles = Article.order(created_at: :desc).page(params[:page])
serializable_resource = ActiveModelSerializers::SerializableResource.new(
articles,
includes: '**',
each_serializer: ArticleSerializer,
current_user: current_user
)
json = { articles: serializable_resource.as_json }
render json: json
end
end
end
end
ArticleSerializer
class ArticleSerializer < ActiveModel::Serializer
attributes :id, :title, :content, :image_url, :is_read
def is_read
current_user = @instance_options[:current_user]
UserArticle.where(user: current_user, article: object).exists?
end
end
小ネタ(おまけ)
引数を用いた条件分岐
引数を用いてSerializerで作るJSONを出し分けたい時やnilガードを仕込みたい時などは、以下のように引数を用いた条件分岐を実装することも可能です。
JSONのキーを出し分ける場合は、attributes
と別にattribute
を定義する必要があるので注意です。
ArticleSerializer
class ArticleSerializer < ActiveModel::Serializer
attributes :id, :title, :content, :image_url
attribute :is_read, if: :current_user?
def current_user?
@instance_options.key?(:current_user)
end
def is_read
return false if @instance_options[:user_articles].nil?
UserArticle.where(user: current_user, article: object).exists?
end
end
privateメソッドは使えない
内部でしか使わないメソッドを以下のようにprivateメソッドに閉じ込めたいのですが、そのようにするとエラーが返ってきます。
ArticleSerializer
class ArticleSerializer < ActiveModel::Serializer
attributes :id, :title, :content, :image_url
attribute :is_read, if: :current_user?
def is_read
UserArticle.where(user: current_user, article: object).exists?
end
private
# こういう感じにはできない
def current_user?
return false if @instance_options[:user_articles].nil?
@instance_options.key?(:current_user)
end
end
エラー文
NoMethodError: private method `current_user?' called for #<ArticleSerializer:0x000055729750f848>
from /usr/local/bundle/gems/active_model_serializers-0.10.10/lib/active_model/serializer/field.rb:62:in `public_send'
N+1回避
クラスの設計にもよりますが、先ほどのようにSerializerの中でモデルに対しwhere
やexists?
を使ってDBに問い合わせを行うとN+1が発生する原因になり得ます(実は、先ほどのSerializerの使い方でもN+1が発生します)。
その場合は、Controller側で先にDBへの問い合わせをまとめて行い、その結果を引数に渡すなどの工夫をすれば良いでしょう。
ArticleController
module Api
module V1
class ArticlesController < Api::ApplicationController
def index
articles = Article.order(created_at: :desc).page(params[:page])
# ここで問い合わせを行い、先にハッシュを作っておく
user_articles = current_user.user_articles.where(article_id: articles.map(&:id)).pluck(:article_id)
serializable_resource = ActiveModelSerializers::SerializableResource.new(
articles,
includes: '**',
each_serializer: ArticleSerializer,
user_articles: user_articles # 先ほど作ったハッシュを引数に渡す
)
json = { articles: serializable_resource.as_json }
render json: json
end
end
end
end
ArticleSerializer
class ArticleSerializer < ActiveModel::Serializer
attributes :id, :title, :content, :image_url
attribute :is_read, if: :user_articles?
def user_articles?
@instance_options.key?(:user_articles)
end
def is_read
return false if @instance_options[:user_articles].nil?
# DBではなく引数で指定したハッシュに対し問い合わせを行う
@instance_options[:user_articles].include?(object.id)
end
end