Skip to main content

シリアライザー

シリアライザーはControllerからデータをオブジェクトとしてクライアントに返す時に、modelから特定のカラムを削除したり、特定のカラムのみを返したりする機能である。

シリアライザーはユーザーの個人情報や非公開の投稿などを返さないように制御する仕組みである。 REST APIでもGraphQLでもこの辺りの実装を疎かにすると、誰でもユーザーのメールアドレスが見れてしまったり、 個人用で投稿したのに、誰でもそれを閲覧できてしまったりする。

セキュリティ面も考慮して使いやすいシリアライザーが必要である。

json seriarlizerの作成

この記事にある通り、Gem も多くあるが自作する。

https://shrkw.hatenablog.com/entry/2020/07/16/123000

app/models/application_record.rbを開き、以下を書き込む。

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": "かばん"
}

と返ってくる。

利用できるオプションは:onlyexceptmethodである。

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_atcontentである。 まずは、これらをserializerを利用して、削除する。

app/models/post.rbを開いて、以下を追加する。

app/models/post.rb
private

def json_for_list
{
except: [
:content,
:updated_at,
]
}
end

app/controllers/posts_controller.rbを開き、index actionを編集する。 シリアライザーを利用するときは、json_for_listjson_for: :listみたいに書き換える。

app/controllers/posts_controller.rb
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である。

app/controllers/posts_controller.rb
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がある程度、プライバシーに関わる情報(アクセストークンとか)は保護しているものの、 emailproviderが含まれている。 postモデルと同じように、Userでもjson serializerを利用し、余分なカラムを削除する。

caution

理由はわからないが、Userモデルの場合、exceptが利用できない。onlyは利用できるのでそちらを使ってシリアライズする。

userモデルのファイルを開き、公開情報用のシリアライザーとプライベートを記述する。

app/models/user.rb
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もシリアライズする。

app/controllers/posts_controller.rb
# 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アクションが返す値も変更しておく。 これは次のユーザーモジュールでプライベートな情報を更新する時に利用する。

app/controllers/auth/sessions_controller.rb
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"