mmag

ハマったことメモなど

PhoenixでモデルのSchemaに無い属性をレスポンスのJSONに含める

はいっこんばんは。

PhoenixJSON APIを書いてて迷ったことがあったので書きます。見落としている簡単なやり方があるんじゃないかと。

前提

UserモデルとPostモデルがあり、UserPostをlikeできるとします。postsテーブルのカラムはidtitlebodyがあり、likesテーブルにはiduser_idpost_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引数のキーである:postPostView.__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時半。

テーマはFB matteをベースにしてます。作者さんに感謝を込めて。