Notes to self

GraphQL subscriptions with Elixir and Absinthe

Here are some notes on WebSockets-based Absinthe GraphQL subscriptions. A basic setup and familiarity with regular queries and mutations is assumed.

Before we write our first subscription we need to ensure that the Absinthe.Subscription process will be started:

# lib/app/application.ex
...
  # Start the endpoint when the application starts
  AppWeb.Endpoint,

  # GraphQL subscriptions
  {Absinthe.Subscription, [AppWeb.Endpoint]},
...

Next we have to update our Phoenix application endpoint to have the new WebSocket endpoint in place. Most people would want to start with one endpoint, use WebSockets, and handle the connection per user, hence pointing to AppWeb.UserSocket module for authentication.

# lib/app_web/endpoint.ex
defmodule AppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :app
  use Absinthe.Phoenix.Endpoint

  socket "/socket", AppWeb.UserSocket,
    websocket: true,
    longpoll: false
...

AppWeb.UserSocket module needs to be defined of course. Here is an example of authenticating this socket connection using Guardian and OAuth 2 bearer token. We either return {:ok, socket} tuple for successful connection or :error atom to refuse the request. Also notice the Absinthe.Phoenix.Socket.put_options function call to add the user identifier (we will use this soon in the subscription):

# lib/app_web/channels/user_socket.ex
defmodule AppWeb.UserSocket do
  use Phoenix.Socket

  use Absinthe.Phoenix.Socket,
    schema: DigiWeb.Private.Schema

  def connect(%{"Authorization" => header_content}, socket) do
    [[_, token]] = Regex.scan(~r/^Bearer (.*)/, header_content)

    case Guardian.Phoenix.Socket.authenticate(socket, DigiWeb.Public.Guardian, token) do
      {:ok, authed_socket} ->
        user_id = authed_socket.assigns.guardian_default_claims["sub"]

        new_socket =
          Absinthe.Phoenix.Socket.put_options(authed_socket,
            context: %{
              user_id: user_id
            }
          )

        {:ok, new_socket}

      {:error, _} ->
        :error
    end
  end

  # This function will be called when there was no authentication information
  def connect(_params, _socket) do
    :error
  end

  def id(_socket), do: nil
end

Once the connection itself is ready, we can update our GraphQL schema with a new subscription. I am including two similar yet different examples of a subscription for a file download. :user_download_ready uses the socket context to publish to a “user_downloads:USER_ID” topic and :download_ready uses a :download_id identifier instead to publish to a “downloads:DOWNLOAD_ID” topic (this would require the download request mutation to return such ID):

defmodule MyWeb.GraphQL.Schema do
  ...
  subscription do
    field :user_download_ready, :download_link do
      config(fn args, context ->
        {:ok, topic: "user_downloads:#{context[:context][:user_id]}"}
      end)

      resolve(fn link, _, _res ->
        {:ok, link}
      end)
    end

    field :download_ready, :download_link do
      # Unique download ID of the job
      arg(:download_id, non_null(:string))

      config(fn args, _context ->
        {:ok, topic: "downloads:#{args.download_id}"}
      end)

      resolve(fn link, _, _res ->
        {:ok, link}
      end)
    end
  end
end

If we would want to test this setup without initiating a download request (like sending a mutation) we can use Absinthe.Subscription.publish to publish new link that will be then resolve with the above resolver function. We can then watch console output on what’s happening.

Absinthe.Subscription.publish(
  AppWeb.Endpoint,
  %{url: "testurl"},
  user_download_ready: "user_downloads:1"
)

If we want to test our new subscription using GraphiQL we need to update our router to point GraphiQL to the right socket module:

# lib/app_web/router.ex
...
  scope "/api/graphiql" do
    pipe_through [:browser, :authenticate_on_post_only]

    forward "/", Absinthe.Plug.GraphiQL,
      schema: AppWeb.GraphQL.Schema,
      socket: AppWeb.UserSocket
...

And then make sure that we are setting the correct WS URL for the WebSocket connection next to the usual URL in the top part of the GraphiQL. In case of using SSL/TLS this would be:

  • URL: https://localhost:4443/graphql
  • WS URL: wss://localhost:4443/socket

At last we just send the subscription request and wait for the results to appear as they come:

subscription {
  userDownloadReady() {
    url
  }
}

Note that in our case the user is determined by the OAuth 2 bearer token in the Authorization header in the format of Bearer TOKEN that we added in the top part of the GraphiQL under the API and WS URLs.

Work with me

I have some availability for contract work. I can be your fractional CTO, a Ruby on Rails engineer, or consultant. Write me at strzibny@strzibny.name.

RSS