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"]が渡ってきます。使う場所によって微妙に動きを変えたいときに使う感じですかね。

Erlang 20.1.7でglobal名が衝突してtakeoverが失敗する

www.manning.com

この本を読みながら遊んでいたらハマった。

chuckyというアプリケーションをつくってfailoverとtakeoverを試してみる章で、failoverはうまくいくけどtakeoverできない、という現象が見られた。 A, B, Cという3つのnodeがあり、Aでchuckyが動いているとする。

A [chucky]
B
C

ここでAが死ぬ

🔥
B [chucky]
C

すると、failoverしてBでchuckyが動きはじめる。ここでAが復活すると、本来はtakeoverして以下のようにAでまたchuckyが起動するが、なぜかここがコケる。

A [chucky]
B
C

どのようにコケるかというと、chuckyが立ち上げているプロセスのうちglobalな名前を持っているものが、その名前のプロセスはもう立ち上がっているよ、というエラーで立ち上がれない、というもの。つまり、Aがtakeoverして立ち上がりプロセスにglobalな名前をつけようとしたところで、その名前はBで使われていて落ちる。

回避策として、最初はglobalな名前を付けずに立ち上げて、起動後に:global.re_register_name/2をつかう、とするべきなのかなあと思ったけど、そんなことしないといけないんだっけ、と思って色々試したところ、Erlangのバージョンを今の最新の20.2.2に上げたら本の通りに動いた。失敗していたときはErlang 20.1.7。ちなみにElixirは1.6.0。

原因はよくわかっていないのでもにょり。

one_for_allとrest_for_allはtemporaryなプロセスの死で発火しない

再起動戦略one_for_allのSupervisorがいたとして、そこにtemporaryなWorkerとpermanentなWorkerがぶら下がっているとします。

permanentがWorkerがクラッシュすると、temporaryなWorkerが落とされて、両方再起動します。one_for_allなので。

しかしながら、temporaryなWorkerがクラッシュしても、その後何も起きません。temporaryなので。

もし再起動してほしいときは、例えば両Workerをリンクさせて、temporaryのWorkerが落ちたときにpermanentのWorkerに:EXITメッセージが飛ぶようにして、受け取ったpermanentなWorkerはstopするようにしておく、などちょっと工夫が必要。

という学びでした。

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