技術メモ

プログラミングとか電子工作とか

Phoenix入門 (第7章 Controllers その3)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。 Controllersの続きです。今回で終われるかな?

Redirection

Phoenix上でのリダイレクトの方法のようです。
アイテムを作成した後に表示画面にリダイレクトしたいときなんかに使えそうです。例えばlocalhost:400/create/1234にPOSTした後にlocalhost:400/show/1234にリダイレクトする感じでしょうか。
内部のパスにリダイレクトする際と外部にリダイレクトする際(例えばいかがわしいページで18歳以上ですか?→いいえ→Yahoo、などでしょうか)で少々指定の方法が違うようです。
まずは内部の例から。lib/hello_phoenix_web/router.exを編集します。

defmodule HelloPhoenixWeb.Router do
  use HelloPhoenixWeb, :router
  ...

  scope "/", HelloPhoenixWebdo
    ...
    get "/", PageController, :index
  end

  # New route for redirects
  scope "/", HelloPhoenixWebdo
    get "/redirect_test", PageController, :redirect_test, as: :redirect_test
  end
  ...
end

お次にlib/hello_phoenix_web/controllers/page_controller.exを編集します。

defmodule HelloPhoenixWeb.PageController do
  use HelloPhoenixWeb, :controller

  def index(conn, _params) do
    # render(conn, "index.html")
    redirect(conn, to: "/redirect_test")
  end

  def redirect_test(conn, _params) do
    text(conn, "Redirect!")
  end
end

どうやらlocalhost:4000/にアクセスしたらlocalhost:4000/redirect_testに飛ぶように設定しているようです。
リダイレクト先の表示はプレーンテキストになっています。
本来であればレンダリング関数が書かれているところにredirect/2関数を記載し、第二引数にto: "リダイレクト先のパス"という書き方のようです。

リダイレクトされている状況は開発者ツールなどで閲覧できます。Chromeの場合はCtrl + Shift + IででるウィンドウのNetworkというタブで確認可能です。

f:id:ysmn_deus:20190308181914p:plain

302ステータスになっています。

外部にリダイレクトするときはexternal: "外部URL"のようです。

def index(conn, _params) do
  redirect(conn, external: "https://elixir-lang.org/")
end

ルーティングの章で学んだPath helperを利用してのリダイレクトも可能なようです。

  def index(conn, _params) do
    redirect(conn, to: Routes.redirect_test_path(conn, :redirect_test)) # redirect_test_urlではない、to: の対象は”パス”
  end

個人的にはハードコードを極力避けたいのでこっちの方が安心感はあるかな?とは思います。

Action Fallback

アクションのエラーを毎回書くのは面倒です。ここではブログの記事を取ってくるURLにリクエストが来たときのエラー処理を想定しています。
例えば、そもそも存在しない記事を参照しようとした場合、404でNotFoundの処理をするでしょう。もしくは特定の人しか閲覧できないような記事の場合、認証に失敗して403を返す時もあるでしょう。
こういう処理をアクション毎に書いていくのは冗長でElixirっぽくないです。やはりこういう処理を回避する方法が用意されています。

ここでの例は上記でも申し上げた通り、ブログの記事を取ってくるアクションを想定します。
Blog.fetch_post/1という関数でidpost情報を取得し、Authorizer.authorize/3でそのポスト情報が現在のユーザーで閲覧可能かどうかチェックしています。
Blog.fetch_post/1で失敗すると{:error, :not_found}が、Authorizer.authorize/3で失敗すると{:error, :unauthorized}が返ってきます。

defmodule HelloPhoenixWeb.MyController do
  use Phoenix.Controller
  alias HelloPhoenix.{Authorizer, Blog}
  alias HelloPhoenix.ErrorView

  def show(conn, %{"id" => id}, current_user) do
    with {:ok, post} <- Blog.fetch_post(id),
         :ok <- Authorizer.authorize(current_user, :view, post) do

      render(conn, "show.json", post: post)
    else
      {:error, :not_found} ->
        conn
        |> put_status(:not_found)
        |> put_view(ErrorView)
        |> render(:"404")
      {:error, :unauthorized} ->
        conn
        |> put_status(403)
        |> put_view(ErrorView)
        |> render(:"403")
    end
  end
end

一箇所だけなら必要な処理なので仕方ないとは思うのですが、別のアクションでも同様の処理を書いていくとなると保守性にも優れませんのでこのエラー処理はどこかで一元管理したいところです。
そこで別にコントローラを作成してそれを再利用することにします。

defmodule HelloPhoenixWeb.MyFallbackController do
  use Phoenix.Controller
  alias HelloPhoenixWeb.ErrorView

  def call(conn, {:error, :not_found}) do
    conn
    |> put_status(:not_found)
    |> put_view(ErrorView)
    |> render(:"404")
  end

  def call(conn, {:error, :unauthorized}) do
    conn
    |> put_status(403)
    |> put_view(ErrorView)
    |> render(:"403")
  end
end

これを必要に応じてコントローラでaction_fallbackマクロで読み込みます。

defmodule HelloPhoenixWeb.MyController do
  use Phoenix.Controller
  alias HelloPhoenix.{Authorizer, Blog}

  action_fallback HelloPhoenixWeb.MyFallbackController

  def show(conn, %{"id" => id}, current_user) do
    with {:ok, post} <- Blog.fetch_post(id),
         :ok <- Authorizer.authorize(current_user, :view, post) do

      render(conn, "show.json", post: post)
    end
  end
end

かなりスッキリしました!
これもコントローラがPlugである恩恵でしょう、action_fallbackも内部はPlugで実装されているようです。

Halting the Plug Pipeline

上記でもPlugの有用性に関心しましたが、コントローラはPlugで連なっている構造になっています。
エラーが起こるなど、時と場合によってはPlugの処理中に後の処理をする必要がなくPlugの処理を止めたい時があるとおもいます。そういうときの為にPlug.Conn.halt/1という関数が用意されているようです。

ここではHelloPhoenixWeb.PostFinderというPlugを想定します。これはブログなどの投稿を探してきてconnassignしてくれる(接続情報にポスト情報を載っけてくれる)Plugを想定しています。
もし見つからない場合は404ページを返します。

defmodule HelloPhoenixWeb.PostFinder do
  use Plug
  import Plug.Conn

  alias HelloPhoenix.Blog

  def init(opts), do: opts

  def call(conn, _) do
    case Blog.get_post(conn.params["id"]) do
      {:ok, post} ->
        assign(conn, :post, post)
      {:error, :notfound} ->
        conn
        |> send_resp(404, "Not found")
    end
  end
end

上記の例では404のレスポンスを返しても後になにがしかのPlugが連なっている場合は処理が走るようです。(レンダリングされる所には反映されないかもしれませんが、無駄な処理です。)
そこで、404のケースにはhalt/1関数を入れます。

    ...
    case Blog.get_post(conn.params["id"]) do
      {:ok, post} ->
        assign(conn, :post, post)
      {:error, :notfound} ->
        conn
        |> send_resp(404, "Not found")
        |> halt()
    end

halt/1関数は内部的にはPlug.Conn.tの値をtrueにするだけの関数のようです。
全てのPlugはこのPlug.Conn.ttrueである場合はただ接続情報を渡すだけの存在になり果てるようです。

def post_authorization_plug(%{halted: true} = conn, _), do: conn
def post_authorization_plug(conn, _) do
  ...
end

また、このhalt/1関数はPlug.Conn.ttrueにするだけなので、実際呼び出されたPlugの処理は止まりません。次のPlug以降に影響します。
なので

conn
|> send_resp(404, "Not found")
|> halt()

と書こうが

conn
|> halt()
|> send_resp(404, "Not found")

と書こうが、処理は同じになるようです。

ようやくControllersが終わりました。
Controllersの章を総括しても、やはりいかにPlugの概念を理解し応用するかがPhoenixでの生産性を高める結果に繋がりそうです。