FIVETEESIXONE

Phoenix でバージョニングした REST API を構築する


簡単な API サーバーを作る必要があったので、Elixir の WAF である Phoenix を使ってみました。API バージョンでスコープを分けた RESTful API 構成にしようとしたのですが、なんだか結構ハマってしまったので簡単な手順を残します。

titlecompleted という2つのフィールドを持つ todo という単純なリソースを例とします。

アプリケーションの作成

phoenix.new タスクで新規の Phoenix アプリケーションを作成します。このとき、今回は REST API リソースを構築するので、アセットを管理するためのツールである Brunch は不要です。したがって --no-brunch オプションをつけて実行します。

$ mix phoenix.new rest_api_example --no-brunch

これで、Phoenix アプリケーションの雛形が作成されるので、まずは出力された指示にしたがってデータベースの作成をします。

ただ、以下のエラーが出力されて、データベースの作成に失敗することがあります。

** (Mix) The database for repo HelloPhoenix.Repo couldn't be created, reason given: psql: FATAL:  role "postgres" does not exist

これは、Phoenix が利用する postgres というロールが Postgres データベースに存在しないことによるエラーです。したがって、その場合には CREATEDB 権限を持った postgres ロールを作成しておきます (この設定は config/dev.ex などの設定を変更することによって変更することができるようです) 。

$ psql -d postgres
=# CREATE ROLE postgres LOGIN CREATEDB;

データベースが作られたら、デフォルトで生成されるものの中で、使わない不要なファイルを削除しておきます。以下の通り、今回は利用しない page リソースのファイルを削除してください。

$ rm web/controllers/page_controller.ex web/views/page_view.ex test/controllers/page_controller_test.exs test/views/page_view_test.exs

これで、アプリケーションが作成され、REST API リソースを実装していく準備ができました。

REST API リソースの実装

Phoenix はリソースを生成するための scaffolding 機能を持っていて、mix タスクによって実行できます。今回は API リソースの実装をするので、phoenix.gen.json タスクを使います。

$ mix phoenix.gen.json Todo todos title:string completed:boolean

これを実行すると、以下のファイルが生成されます。

  • priv/repo/migrations/YYYYMMDDHHMMSS_create_todo.exs : マイグレーションファイル
  • web/models/todo.ex : モデル
  • test/models/todo_test.exs : モデルのテストファイル
  • web/controllers/todo_controller.ex : コントローラー
  • web/views/todo_view.ex : ビューファイル
  • test/controllers/todo_controller_test.exs : コントローラーのテストファイル
  • web/views/changeset_view.ex : Ecto モデルのチェンジセットファイル (まだよくわかってない…)

リソースのルーティング

タスクを実行したときに出力された通り、web/router.extodos リソースのルーティング設定をします。ただ、今回は v1 のようにバージョニングされた API リソースとするため、出力されたルーティング設定をそのまま記載するのではなく、バージョンごとにスコープを分けたルーティングにします。

web/router.ex を以下のように修正します。

defmodule RestApiExample.Router do
  use RestApiExample.Web, :router

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", RestApiExample do
    pipe_through :api

    scope "/v1", V1, as: :v1 do
      resources "/todos", TodoController
    end
  end
end

API リソースでは利用しない設定をざっくり削除して、必要な設定のみを追加しています。

ルーティングの設定は phoenix.routes タスクで確認できます。

$ mix phoenix.routes
v1_todo_path  GET     /v1/todos           RestApiExample.V1.TodoController :index
v1_todo_path  GET     /v1/todos/:id/edit  RestApiExample.V1.TodoController :edit
v1_todo_path  GET     /v1/todos/new       RestApiExample.V1.TodoController :new
v1_todo_path  GET     /v1/todos/:id       RestApiExample.V1.TodoController :show
v1_todo_path  POST    /v1/todos           RestApiExample.V1.TodoController :create
v1_todo_path  PATCH   /v1/todos/:id       RestApiExample.V1.TodoController :update
              PUT     /v1/todos/:id       RestApiExample.V1.TodoController :update
v1_todo_path  DELETE  /v1/todos/:id       RestApiExample.V1.TodoController :delete

ルーティングの設定が済んだので、ここでデータベースのマイグレーションをしておきます。

$ mix ecto.migrate

バージョニングのスコープへの適合

リソースファイルの生成とルーティングの設定をしましたが、生成されたファイルの構成は今回のバージョニングのスコープと一致していません。そのスコープに適合させるため、以下のようにディレクトリ構成を変更します。

$ mkdir -p web/controllers/v1
$ mv web/controllers/todo_controller.ex web/controllers/v1
$ mkdir -p web/views/v1
$ mv web/views/todo_view.ex web/views/v1
$ mkdir -p test/controllers/v1
$ mv test/controllers/todo_controller_test.exs test/controllers/v1/

また、モジュール名も合わせて変更しておく必要があります。

web/controllers/v1/todo_controller.ex

defmodule RestApiExample.V1.TodoController do
.
end

test/controllers/v1/todo_controller_test.exs

defmodule RestApiExample.V1.TodoControllerTest do
...
end

web/views/v1/todo_view.ex

defmodule RestApiExample.V1.TodoView do
...
end

バージョニングのスコープに合わせた修正ができたので、一度テストを実行してみます。

$ mix test

だらららら〜、とコンパイルのログが流れて…げ、コンパイルエラーになったぞ。

** (CompileError) test/controllers/v1/todo_controller_test.exs:14: function todo_path/2 undefined
    (stdlib) lists.erl:1336: :lists.foreach/2
    (stdlib) erl_eval.erl:657: :erl_eval.do_apply/6

この辺りが今回一番ハマったところなんですが、これは、出力されているように、todo_path/2 というヘルパーが定義されていない、というエラーです。なぜ定義されていないかというと、ルーティングで v1 というスコープを指定すると、as: :v1 というオプションを指定したことになり、その場合には、todo_path/2 ヘルパーには v1_ という prefix を付ける必要があるようです。したがって、テストを修正する必要があります。したがって、test/controllers/v1/todo_controller_test.exstodo_path をすべて v1_todo_path に変更してください。

修正してもう一度 mix test を実行すると…

** (UndefinedFunctionError) undefined function: RestApiExample.TodoView.__resource__/0 (module RestApiExample.TodoView is not available)

またもエラー…この原因はどうやら、ビューのコードの中で render_many/2render_one/2 を実行しているところがあるんですが、今回のようにバージョニングのスコープを指定している場合、これらが更にその内部で実行している Phoenix.View.render_many/3Phoenix.View.render_many/3 が期待通りに動作しないことにあるようです。したがって、以下のように、ビューファイルの中で直接それらを実行するように修正する必要があります。

web/views/v1/todo_view.ex

defmodule RestApiExample.V1.TodoView do
  use RestApiExample.Web, :view

  def render("index.json", %{todos: todos}) do
    %{data: render_many(todos, RestApiExample.V1.TodoView, "todo.json")}
  end

  def render("show.json", %{todo: todo}) do
    %{data: render_one(todo, RestApiExample.V1.TodoView, "todo.json")}
  end

  def render("todo.json", %{todo: todo}) do
    %{id: todo.id}
  end
end

これでやっとテストが通るようになりました。

$ mix test
.............

Finished in 0.7 seconds (0.4s on load, 0.2s on tests)
13 tests, 0 failures

この辺りのエラーに関しては、以下の記事を参考にしました。この記事がなければ投げ出していたかもしれません。感謝。 Building a versioned REST API with Phoenix Framework

ビューの JSON を修正

今のビューの設定だと、Todo リソースを参照したときの JSON がその ID だけしか含まなくて寂しいので、title などもわかるように修正します。

web/views/v1/todo_view.ex

defmodule RestApiExample.V1.TodoView do
  ...

  def render("todo.json", %{todo: todo}) do
    %{id: todo.id, title: todo.title, completed: todo.completed}
  end
end

API にアクセスする

それでは、超簡単なものですがこれで REST API ができたので、早速アクセスしてみましょう。

サーバーの起動

$ mix phoenix.server

Todo リソースの作成

$ curl -H "Content-Type: application/json" -X POST -d '{"todo":{"title":"Buy a Wii U","completed":true}}' http://localhost:4000/v1/todos
{"data":{"title":"Buy a Wii U","id":1,"completed":true}}
$ curl -H "Content-Type: application/json" -X POST -d '{"todo":{"title":"Get A+","completed":false}}' http://localhost:4000/v1/todos
{"data":{"title":"Get A+","id":2,"completed":false}}

すべての Todo リソースの取得

$ curl -H "Content-Type: application/json" http://localhost:4000/v1/todos
{"data":[{"title":"Buy a Wii U","id":1,"completed":true},{"title":"Get A+","id":2,"completed":false}]}

ID を指定して取得

$ curl -H "Content-Type: application/json" http://localhost:4000/v1/todos/2
{"data":{"title":"Get A+","id":2,"completed":false}}

おわりに

ショボいながらも、Phoenix をこれではじめてまともに触ったのですが、Rails を使ったことがあるのなら全体の構造は初見でも大体把握できるし、ドキュメントや情報も思った以上に充実している印象でした。ただ、Phoenix はまだ新しく頻繁に互換性の無い変更が入っているようなのと (色々なところで公開されている下位バージョンのサンプルコードがそのまま動くことがほとんどなかった) 、これはまぁ当然だけど Elixir のパターンマッチとかをちゃんと理解してないと簡単な機能とかテストの実装も苦労します。また、今回のようなバージョニングだと、今後バージョンアップのたびに大量のコピペコードが登場することになるだろうと思うので、本気で構築するならもっと良い方法を検討する必要があるでしょう。