シリアライザー
シリアライザーはControllerからデータをオブジェクトとしてクライアントに返す時に、modelから特定のカラムを削除したり、特定のカラムのみを返したりする機能である。
シリアライザーはユーザーの個人情報や非公開の投稿などを返さないように制御する仕組みである。 REST APIでもGraphQLでもこの辺りの実装を疎かにすると、誰でもユーザーのメールアドレスが見れてしまったり、 個人用で投稿したのに、誰でもそれを閲覧できてしまったりする。
セキュリティ面も考慮して使いやすいシリアライザーが必要である。
json seriarlizerの作成
この記事にある通り、Gem も多くあるが自作する。
https://shrkw.hatenablog.com/entry/2020/07/16/123000
app/models/application_record.rbを開き、以下を書き込む。
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
# @return [String]
SERIALIZABLE_METHOD_OPTION_SUFFIX = '_sub'.freeze
# @return [Regexp]
SERIALIZABLE_METHOD_OPTION_SUFFIX_REGEX = /#{SERIALIZABLE_METHOD_OPTION_SUFFIX}\z/
# @see: https://github.com/rails/rails/blob/v5.1.4/activemodel/lib/active_model/serialization.rb
def serializable_hash(options = nil)
options ||= {}
default_options ||= {}
default_options = send('json_for_' + options.fetch(:json_for, :default).to_s) if options[:json_for]
super(default_options.merge(options)).each_with_object({}) do |(k, v), hash|
key = k.gsub(/[!?]/, '').sub(SERIALIZABLE_METHOD_OPTION_SUFFIX_REGEX, '')
value = v.is_a?(Time) ? v.utc.iso8601 : v
hash[key] = value
end
end
end
このコードはコピペで持ってきているので、コードの解説はできない。 ApplicationRecord クラスは、全てのモデルで継承されるので、この Serializer はどのモデルでも利用できるようになる。
例えば、app/models/post.rb では 、現在このようなスキーマになっている。
t.integer "user_id"
t.string "title"
t.text "content"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
Controller内でモデルからオブジェクトを取得して json で返すと、全てのカラムが返ってくる。
@post = Post.find(1)
render json: @posts
{
"id": 1,
"user_id": 1,
"title": "かばん",
"content": "グレゴリー",
"created_at": "2022-07-21T03:17:31Z",
"updated_at": "2022-07-21T03:17:31Z",
}
この時post.rbにこのような private な serializer メソッドを作っておくと、目的に合わせて必要なカラムを返すことができる。
private
def json_for_list
{
except: [
:content,
:updated_at,
]
}
end
def json_for_only_title
{
only: [
:title
]
}
end
json_for_listを利用したい時は
render json: @posts, json_for: :list
とすると、:content, :updated_at, :thumbnailを除いた値が返ってくる。
{
"id": 1,
"user_id": 1,
"title": "かばん",
"created_at": "2022-07-21T03:17:31Z"
}
と返ってくる。
json_for_only_titleは:titleのみ返ってくる。
render json: @posts, json_for: :only_title
{
"title": "かばん"
}
と返ってくる。
利用できるオプションは:onlyとexcept とmethodである。
https://github.com/rails/rails/blob/v5.1.4/activemodel/lib/active_model/serialization.rb
git add .
git commit -m "Create a serializer"
serializerを利用する
http://localhost:3001/postsを叩くと、このような結果が返ってくる。
[
{
"id": 41,
"user_id": 2,
"title": "a",
"content": "a",
"created_at": "2022-07-21T03:34:31Z",
"updated_at": "2022-07-21T03:34:31Z",
},
{
"id": 40,
"user_id": 1,
"title": "かばん",
"content": "グレゴリー",
"created_at": "2022-07-21T03:17:31Z",
"updated_at": "2022-07-21T03:17:31Z",
}
]
このデータはトップページで使っていて、いくつか不要な情報と、必要だけど入っていない情報がある。
不必要な情報はフロントのUIを見れば分かる様に、update_atとcontentである。
まずは、これらをserializerを利用して、削除する。
app/models/post.rbを開いて、以下を追加する。
private
def json_for_list
{
except: [
:content,
:updated_at,
]
}
end
app/controllers/posts_controller.rbを開き、index actionを編集する。
シリアライザーを利用するときは、json_for_listをjson_for: :listみたいに書き換える。
def index
@posts = Post.all.order(created_at: :desc)
render json: @posts, json_for: :list
end
戻ってくる値がこの様になる。
[
{
"id": 41,
"user_id": 2,
"title": "a",
"created_at": "2022-07-21T03:34:31Z"
},
{
"id": 40,
"user_id": 1,
"title": "かばん",
"created_at": "2022-07-21T03:17:31Z"
}
]
リレーションをinclude
上のjsonではuserの情報はuser_idだけで、userモデルの情報が入っていない。 モデルでリレーションが貼られている先のモデルを含める関数がincludeである。
def index
@posts = Post.eager_load(:user).all.order(created_at: :desc)
render json: @posts, json_for: :list, include: :user
end
上のコードでは変更点が2つある。
1つめがincludeで、:userを指定することでPostのuser_idを見て、それに紐づくUserモデルを加えてくれる。
もう1つはPost.eager_load(:user)である。
indexアクションでは複数のpostを返す。postにはそれに紐づくUserがおり、例えばpostを100個用意した時に、
それを作成したユーザーが全て異なるユーザーである場合、そのユーザー情報を取得するためにRailsは
100回データベースに問い合わせることになる。
これを通称N+1問題という。Railsの場合、回避方法は簡単で、post取得時にあらかじめUserをロードするeager_load()を使えば良い。
再びAPIを叩くと、この様なJSONが返ってくる。
[
{
"id": 41,
"user_id": 2,
"title": "a",
"created_at": "2022-07-21T03:34:31Z",
"user": {
"id": 2,
"provider": "email",
"uid": "tom@example.com",
"allow_password_change": false,
"name": null,
"nickname": null,
"image": null,
"email": "tom@example.com",
"created_at": "2022-07-21T03:32:12.622Z",
"updated_at": "2022-07-21T03:34:22.363Z",
}
},
{
"id": 40,
"user_id": 1,
"title": "かばん",
"created_at": "2022-07-21T03:17:31Z",
"user": {
"id": 1,
"provider": "email",
"uid": "alice@example.com",
"allow_password_change": false,
"name": null,
"nickname": null,
"image": null,
"email": "alice@example.com",
"created_at": "2022-06-30T05:31:13.329Z",
"updated_at": "2022-07-21T03:10:34.291Z",
}
}
]
上の結果ではUserモデルは取ってこられた。
Deviseがある程度、プライバシーに関わる情報(アクセストークンとか)は保護しているものの、
emailやproviderが含まれている。
postモデルと同じように、Userでもjson serializerを利用し、余分なカラムを削除する。
caution
理由はわからないが、Userモデルの場合、exceptが利用できない。onlyは利用できるのでそちらを使ってシリアライズする。
userモデルのファイルを開き、公開情報用のシリアライザーとプライベートを記述する。
private
def json_for_public
{
only: [
:id,
:name,
:nickname,
:image,
]
}
end
def json_for_private
{
methods: [
:unconfirmed_email
]
}
end
再びposts_controllerに戻り、index actionを編集する。 ついでに、show actionもシリアライズする。
# GET /posts
def index
@posts = Post.eager_load(:user).all.order(created_at: :desc)
render json: @posts, json_for: :list, include: {user: {json_for: :public}}
end
# GET /posts/1
def show
@post = Post.find(params[:id])
render json: @post, include: {user: {json_for: :public}}
end
indexアクションのAPIを叩くと、無駄な情報が削除され、プライバシーも守られた理想的なjsonが返ってきた。
[
{
"id": 41,
"user_id": 2,
"title": "a",
"created_at": "2022-07-21T03:34:31Z",
"user": {
"id": 2,
"name": null,
"nickname": null,
"image": null,
}
},
{
"id": 40,
"user_id": 1,
"title": "かばん",
"created_at": "2022-07-21T03:17:31Z",
"user": {
"id": 1,
"name": null,
"nickname": null,
"image": null,
}
}
]
もう1つ、sessions_controllerのindexアクションが返す値も変更しておく。 これは次のユーザーモジュールでプライベートな情報を更新する時に利用する。
def index
if current_user
render json: {is_login: true, data: current_user}, json_for: :private
else
render json: { is_login: false, message: "ユーザーが存在しません" }
end
end
git add .
git commit -m "Create a json serializer"