PhoenixでモデルのSchemaに無い属性をレスポンスのJSONに含める
はいっこんばんは。
PhoenixでJSON APIを書いてて迷ったことがあったので書きます。見落としている簡単なやり方があるんじゃないかと。
前提
User
モデルとPost
モデルがあり、User
はPost
をlikeできるとします。posts
テーブルのカラムはid
とtitle
とbody
があり、likes
テーブルにはid
とuser_id
とpost_id
があるとします。タイムスタンプは省きます。
defmodule User do use App.Web, :model schema "users" do field :name, :string has_many :likes, Like end end defmodule Post do use App.Web, :model schema "posts" do field :title, :string field :body, :text has_many :likes, Like end end defmodule Like do use App.Web, :model schema "likes" do belongs_to :user, User belongs_to :post, Post end end
Postの一覧にLike済みかを含める
GET /posts
されたら何を返すかという話なのですが、schemaにある属性をそのまま返すと以下のようになります。
{ "posts": [ {"id": 1, "title": "Hello", "body": "Lorem ipsum"}, ... ] }
post_view.ex
はこんな感じ。
defmodule PostView do use App.Web, :view def render("index.json", %{posts: posts} do %{posts: render_many(posts, PostView, "post.json")} end def render("show.json", %{post: post} do %{posts: render_one(post, PostView, "post.json")} end def render("post.json", %{post: post}) do %{id: post.id, title: post.title, body: post.body} end end
ここで、自分がLike済みか(liked
)をここに含めたいと考えたとき、どうするでしょうか。リクエストヘッダにあるトークンからcurrent_user
を判定しているものとして、以下のようにしたいと。
{ "posts": [ {"id": 1, "title": "Hello", "body": "Lorem ipsum", "liked": true}, ... ] }
EctoでDBから取得
そもそも始めに、EctoでPost
を取ってくるときに、一緒にlike済みかも取得するにはどうすればいいでしょうか。多分こんな。
Post |> join(:left, [p], l in Like, l.post_id == p.id and l.user_id == ^current_user.id) |> select([p, l], %{post: p, liked: not is_nil(l.id)})
Repo.all
すると、以下の様なMapのリストが返ってきます。
[%{post: %Post{...}, liked: true}, %{post: %Post{...}, liked: false}, ... ]
ControllerからViewへ
mix phoenix.gen.json
とかすると、Controllerには以下のようなコードが生成されます。
render(conn, "index.json", posts: posts)
すると上に書いたViewで
%{posts: render_many(posts, PostView, "post.json")}
の結果がJSONにされてレスポンスとなります。render_many/4
のドキュメントによると、以下の2つがザックリ同じだそうです。
render_many(posts, PostView, "post.json") Enum.map posts, fn post -> render(PostView, "show.json", post: post) end
render/3
の結果はPostView.render("show.json", post: post)
となります。余談ですが、render/3
の第3引数のキーである:post
はPostView.__resource__
から来ていて、こちらで指定する場合はrender_many(posts, PostView, "post.json", as: :foo)
と書きます。
このままのコードでは、レスポンスJSONの内容に、post.*
しか入れることができません。つまり、posts
スキーマに含まれていないliked
を入れることができません。ではどこにliked
を入れ込んでいけるか辿っていくと、Controllerのrender(conn, "index.json", posts: posts)
の第3引数まで遡ります。ここにposts
しか渡していないためliked
が返せないということになります。ではEctoでDBから取ってきたこれらをまとめて雑にdata
としてぶち込みます。
# data = [%{post: %Post{...}, liked: true}, %{post: %Post{...}, liked: false}, ... ] render(conn, "index.json", data: data)
Viewを変更
Controllerでrender/3
に与える引数を変えたのでViewも修正する必要があります。
defmodule PostView do use App.Web, :view def render("index.json", %{data: data} do %{posts: render_many(data, PostView, "post.json", as: :data)} end def render("show.json", %{data: data} do %{posts: render_one(data, PostView, "post.json", as: :data)} end def render("post.json", %{data: %{post: post, liked: liked}}) do %{id: post.id, title: post.title, body: post.body, liked: liked} end end
これでなんとかliked
が入り、当初の希望通りのJSONが返せます。
所感
ここまでやって、MVCの中を駆けまわっていたり、結合が密な感じがして、なんか面倒くさくないですか、という気持ちがちょっとだけ。mix
タスクで生成したコードから出発してるからそう感じるだけかもしれませんし、Viewの中でもっと関数定義してパターンマッチすれば、いくつもliked
みたいなものが増えてもそれほど煩雑にならないのかもしれませんが。あとEctoで取ってきたものをそのままViewに投げているので、依存しているように見えてるのかもしませんね。ただ、なんか見落としてんじゃねーかなと思う午前1時半。