やること
これまで作成したデモアプリに、記事情報を取得するためのREST-APIを追加する
generatorを使う場合
コントローラの作成
mixコマンドを使ってAPI用のコントローラを作成する。
$ mix phoenix.gen.json ApiPost posts --no-model
記事を表すPostモデルはすでに存在するので、--no-modelオプションを付ける。
しかし、このままでは生成されたコントローラは存在しないApiPost
というモデルを扱おうとしてしまうので、web/controllers/api_post_controller.exを開き、モデル名をApiPost->Postに置換する。
ルートの追加
web/routes.exを開き、以下の記述を追加
scope "/api", PhoenixSample do
pipe_through :api
resources "/posts", ApiPostController
end
実際にhttp://localhost:4000/api/postsにアクセスしてみると...
$ http -b http://localhost:4000/api/posts
{
"data": [
{
"id": 1
},
{
"id": 2
}
]
}
ルートは有効になっているし、データも取れているようだが、IDしか表示されないのでは話にならない。
ビューの修正
ということで、ビューを修正する。
web/views/api_post_view.exを開き、内容を確認すると
def render("api_post.json", %{api_post: api_post}) do
%{id: api_post.id}
end
という記述に気がつく。
どうやら、generator使用時にmodelのプロパティを省略したため、IDしか表示されてなかったようだ。
そこで、以下のように表示項目を追加
def render("api_post.json", %{api_post: api_post}) do
%{id: api_post.id,
title: api_post.title,
content: api_post.content,
inserted_at: api_post.inserted_at
}
end
改めてアクセスしてみると...
$ http -b http://localhost:4000/api/posts
{
"data": [
{
"content": "希望の朝だ!",
"id": 1,
"inserted_at": "2016-02-08T03:26:39Z",
"title": "新しい朝が来た"
},
{
"content": "輝く緑",
"id": 2,
"inserted_at": "2016-02-18T21:40:06Z",
"title": "新しい朝のもと"
}
]
}
今度はID以外の情報もちゃんと取得できるようになりました。
しかし、今のままでは一覧情報と詳細情報とで同じ情報を取得するようになっている。
ブラウザ向けの画面にしろ、それ以外のクライアント向けのAPIにしろ、リストで取得する情報と
詳細情報で取得する情報は異なるものだと思うので、それに合わせて更にビューを修正することにする。
具体的には、一覧/詳細APIでは以下の様な情報を返すようにする。
- 一覧APIではIDとタイトルと投稿日時、それと詳細APIへのリンクを返す
- 詳細APIではIDとタイトルと内容、それとコメント一覧を返す
- ついでに、一覧APIで取得されるリストにつけられるキー名を
data
からposts
にかえ、詳細APIで取得する情報については、ラップされていないオブジェクトとして返す
viewを変更した結果が以下のとおり
defmodule PhoenixSample.ApiPostView do
use PhoenixSample.Web, :view
def render("index.json", %{posts: posts}) do
%{posts: render_many(posts, PhoenixSample.ApiPostView, "api_post_header.json")}
end
def render("show.json", %{api_post: api_post}) do
render_one(api_post, PhoenixSample.ApiPostView, "api_post_body.json")
end
def render("api_post_header.json", %{api_post: api_post}) do
%{id: api_post.id,
title: api_post.title,
inserted_at: api_post.inserted_at,
link: api_post_path(PhoenixSample.Endpoint, :show, api_post)
}
end
def render("api_post_body.json", %{api_post: api_post}) do
%{id: api_post.id,
title: api_post.title,
content: api_post.content,
inserted_at: api_post.inserted_at,
comments: render_many(api_post.comments, PhoenixSample.ApiPostView, "comment.json")
}
end
def render("comment.json", %{api_post: comment}) do
%{id: comment.id,
name: comment.name,
content: comment.content,
inserted_at: comment.inserted_at
}
end
end
実際にアクセスしてみると...
$ http -b http://localhost:4000/api/posts
{
"posts": [
{
"id": 1,
"inserted_at": "2016-02-08T03:26:39Z",
"link": "/api/posts/1",
"title": "新しい朝が来た"
},
{
"id": 2,
"inserted_at": "2016-02-18T21:40:06Z",
"link": "/api/posts/2",
"title": "新しい朝のもと"
}
]
}
$ http -b http://localhost:4000/api/posts/1
{
"comments": [
{
"content": "喜びに胸を開け",
"id": 2,
"inserted_at": "2016-02-09T17:29:18Z",
"name": "名無し"
},
{
"content": "大空あおげ",
"id": 3,
"inserted_at": "2016-02-09T00:00:00Z",
"name": "通りがかり"
},
{
"content": "ラジオの声に",
"id": 6,
"inserted_at": "2016-02-09T10:33:14Z",
"name": "名無し2"
},
{
"content": "健やかな胸を",
"id": 7,
"inserted_at": "2016-02-13T08:52:45Z",
"name": "3"
}
],
"content": "希望の朝だ!",
"id": 1,
"inserted_at": "2016-02-08T03:26:39Z",
"title": "新しい朝が来た"
}
これで、だいたい欲しかったものは出来上がりました。
ちなみに、詳細APIのURIを生成するために、Viewの中で呼び出している
link: api_post_path(PhoenixSample.Endpoint, :show, api_post)
この関数ですが、ControllerやTemplateのなかでは
api_post_path(conn, :show, api_post)
あるいは
api_post_path(@conn, :show, api_post)
という形で使用されているので、同じようにconnを使った形で書きたかったのですが、controllerから渡されているはずの、このconn
へのアクセスの仕方がわからなかったのでこのように書いています。
こちらの記事によると、引数のmapの中に含まれているようにも見えるのですが、もう少し詳しく調査してみないとわかりません。
(追記)Viewの中で、connにアクセスする方法について
コメントで @uasiさんに教えていただきました。情報ありがとうございます。
render関数の第二引数のキーとしてconnを指定すればアクセス可能とのことなので、
def render("index.json", %{posts: posts}) do
%{posts: render_many(posts, PhoenixSample.ApiPostView, "api_post_header.json")}
end
を
def render("index.json", %{posts: posts, conn: conn}) do
%{posts: render_many(posts, PhoenixSample.ApiPostView, "api_post_header.json")}
end
と変えてみたところ、connにアクセスすることができました。
しかし、このrender_manyを経由して呼び出される
def render("api_post_header.json", %{api_post: api_post}) do
%{id: api_post.id,
title: api_post.title,
inserted_at: api_post.inserted_at,
link: api_post_path(PhoenixSample.Endpoint, :show, api_post)
}
end
を
def render("api_post_header.json", %{api_post: api_post, conn: conn}) do
%{id: api_post.id,
title: api_post.title,
inserted_at: api_post.inserted_at,
link: api_post_path(conn, :show, api_post)
}
end
と変えてみると...
とマッチする関数が見つからないというエラー。
なので、少し戻って、render_manyに渡している引数を、モデルのリストから
link先URIを含んだmapのリストに変えてみる。
def render("index.json", %{posts: posts, conn: conn}) do
posts = posts |> Enum.map(fn(post) -> Map.merge( Map.from_struct(post), %{link: api_post_path(conn, :show, post)}) end)
%{posts: render_many(posts, PhoenixSample.ApiPostView, "api_post_header.json")}
end
def render("api_post_header.json", %{api_post: api_post}) do
%{id: api_post.id,
title: api_post.title,
inserted_at: api_post.inserted_at,
link: api_post.link
}
end
このやり方なら、想定通りの出力が得られましたが、モデルを一度別のマップにいちいち変換しているのが、スマートじゃないような気がするんですよね…
追記2
@uasiさんから、さらなる情報。
たびたびありがとうございます。
render_many/4 の第4引数に conn を入れた Map をセットすると def render("api_post_header.json", %{api_post: api_post, conn: conn}) に渡されますよ:
実際に, render_manyの呼び出しを
render_many(posts, PhoenixSample.ApiPostView, "api_post_header.json", %{conn: conn})
に変えてみたところ、
def render("api_post_header.json", %{api_post: api_post, conn: conn})
にマッチしました。
また、試しにrender_oneの第4引数に同じマップを渡してみたところ、こちらも同様の挙動が確認されました。
render_manyやrender_oneは第1引数(の各要素)を含むマップと第4引数のマップをマージしたものが、最終的にrender関数の第2引数として渡されるようです。
別のやり方
ブラウザ向けの出力とAPI向けの出力を同じコントローラで行う方法が、こちらの記事で紹介されておりました。
こちらも、非常にスマートなやり方だと思います。
ただ、個人的には、statelessに作りたいAPI機能と、セッション情報を使うであろうstatefulなブラウザ向け機能を一つのコントローラで処理しようとすると、お互いの事情の間に色々と齟齬が出て
面倒なことになるんじゃないかとも思います。
参考サイト
[Elixir] PhoenixでJSONを返すWeb APIを作る: http://qiita.com/FL4TLiN3/items/41ca80cdbfca1956ed78
Phoenix のコントローラで render/3 を呼んでからのレンダリング処理の流れを追う: http://qiita.com/uasi/items/a49d9c84d113dd54d0be