mmag

ハマったことメモなど

AbsintheでContextを組み立てたりMiddlewareを挟んだり

最近AbsintheをつかってGraphQLサーバを実装しはじめているのですが、これどう書くんだろと思って調べたことがあったのでメモ。 調べたと言ってもドキュメントに全部書いてあったので、ドキュメント写してるみたいになってる。まあいいよね。

Context

GraphQLの文脈におけるコンテキスト。GraphQLのドキュメントだとexecutionにこう書いてある。

context A value which is provided to every resolver and holds important contextual information like the currently logged in user, or access to a database.

そう、"the currently logged in user"。具体的にはAuthorization HTTPヘッダにあるJWTからユーザIDを読んで、ユーザをDBから引っ張ってきて、それをresolve/1の中で使いたい。結論としてはここに書いてあった。

The Context and Authentication – absinthe v1.4.7

普通にPhoenixでやるのと同様、plugをつくる。ただし、connにassignするのではなくput_privateを使う。初期値の設定などしたかったので構造体を定義してるけど、普通のmapでもOK。

defmodule MyApp.ContextPlug do
  @behaviour Plug

  import Plug.Conn

  defmodule Context do
    defstruct [current_user: nil]
  end

  def init(opts), do: opts

  def call(conn, _opts) do
    context = build_context(conn)
    put_private(conn, :absinthe, %{context: context})
  end

  defp build_context(conn) do
    %Context{}
    |> build_auth_context(conn)
    |> Map.from_struct()
  end

  defp build_auth_context(context, conn) do
    with ["Bearer " <> token | _] <- get_req_header(conn, "authorization"),
         {:ok, user} <- MyApp.User.from_token(token) do
      %{context | current_user: user}
    else
      _ -> context
    end
  end
end

routerに仕込む。

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

  pipeline :graphql do
    plug MyApp.ContextPlug
  end

  scope "/api" do
    pipe_through :graphql

    forward "/graphql", Absinthe.Plug, schema: MyApp.Schema
  end
end

あとはresolveするときに、最後の引数に渡ってくるresolutionからcontextが取れる。

object :user do
  field :is_me, :boolean do
    resolve fn user, _args, resolution ->
      %{current_user: current_user} = resolution.context

      {:ok, user.id == current_user.id}
    end
  end
end

Authorization

認証されてないリクエストに対してはエラーを返したい、というケース。GraphQLの前にplugを置いてしまうと、このfieldだけは誰でもquery投げてOKなんですよというニーズを満たせないので、細かくコントロールできるようにしたい。これにはMiddlewareという仕組みを使います。

Absinthe.Middleware – absinthe v1.4.7

まずはAbsinthe.Middleware behaviourを実装したモジュールを書きます。call/2resolutionを受けてresolutionを返す関数。Absinthe.Resolution.put_result/2resolutionの状態を:resolvedにして結果を確定させる模様。

defmodule MyApp.RequireCurrentUserMiddleware do
  @behaviour Absinthe.Middleware

  def call(resolution, _config) do
    case resolution.context do
      %{current_user: nil} ->
        resolution
        |> Absinthe.Resolution.put_result({:error, "unauthorized"})
      _ ->
        resolution
    end
  end
end

これをcurrent_userが無いときにエラーを返してほしいところへ書きます。

query do
  field :posts, list_of(:post) do
    middleware MyApp.RequireCurrentUserMiddleware

    ...
  end
end

MiddlewareもPlugと同様、単体テストが書きやすそうでいいすね。

ちなみに

middleware MyApp.RequireCurrentUserMiddleware, some: "config"

と書くと、call/2の第2引数に[some: "config"]が渡ってきます。使う場所によって微妙に動きを変えたいときに使う感じですかね。