AbsintheGraphiQL

A plug that serves the GraphiQL 6 IDE for an Absinthe schema. It replaces Absinthe.Plug.GraphiQL from absinthe_plug, which loads an old GraphiQL from a CDN.

GraphiQL 6 is a release candidate. This package bundles graphiql@6.0.0-rc.1 and its 1.0.0-rc companion packages. Expect changes when GraphiQL 6.0.0 ships.

Installation

def deps do
  [
    {:absinthe_graphiql, "~> 0.1.0"}
  ]
end

You do not need Node.js. The built bundle is part of the package.

Usage

Mount the plug where you mounted Absinthe.Plug.GraphiQL:

# lib/my_app_web/router.ex
forward "/graphiql", AbsintheGraphiQL.Plug,
  schema: MyAppWeb.Schema,
  socket_url: "/socket"

A browser GET /graphiql gets the IDE. Every other request goes to Absinthe.Plug.call/2, so the same path also executes queries and mutations sent with POST or GET /graphiql?query=.... The plug serves its assets at /graphiql/__assets__/.

The endpoint must parse request bodies before the router, as for Absinthe.Plug:

plug Plug.Parsers,
  parsers: [:urlencoded, :multipart, :json, Absinthe.Plug.Parser],
  pass: ["*/*"],
  json_decoder: Jason

Most apps only mount the IDE in development:

if Mix.env() == :dev do
  forward "/graphiql", AbsintheGraphiQL.Plug, schema: MyAppWeb.Schema, socket_url: "/socket"
end

Options

All Absinthe.Plug options (:schema, :context, :json_codec, :pipeline and so on) are accepted and passed on.

Option Default Description
:default_url mount path HTTP endpoint for queries and mutations. A relative URL is resolved against the page URL in the browser.
:socket_url nil Path or URL of the Phoenix socket, for example "/socket". http(s):// becomes ws(s)://. Without a socket, subscriptions show an error in the result pane.
:socket nil Socket module, for example MyAppWeb.UserSocket. When :socket_url is not set, the path is looked up in the Phoenix endpoint of the request.
:socket_params %{} Map passed as params to the Phoenix Socket. Written into the page HTML.
:default_query GraphiQL's welcome text Query of the first tab when the browser has no saved state.
:default_variables nil Variables of that tab. A map or a JSON string.
:default_headers nil Request headers of new tabs. A map or a JSON string.
:title "GraphiQL" Page title.
:theme :system :system follows the operating system and lets the user change the theme in the settings dialog. :light and :dark force a theme and hide that setting.
:query_builder true Show the query builder.
:history true Show the history.
:collections true Show collections. They are stored in the browser's localStorage; no account or server is involved.
:should_persist_headers false Save the headers editor in localStorage. Off by default because headers often hold tokens.
:csp true true sends a strict content-security-policy header with the page, false sends none, a string is sent as it is.

:default_url, :socket_url, :socket_params, :default_query, :default_variables and :default_headers can also be a function or a {module, function} tuple. The plug calls it for every page request: with the Plug.Conn when it has arity 1, with no arguments when it has arity 0. When a {module, function} tuple exports both arities, arity 1 is used.

forward "/graphiql", AbsintheGraphiQL.Plug,
  schema: MyAppWeb.Schema,
  default_headers: {__MODULE__, :graphiql_headers}

def graphiql_headers(conn) do
  %{"x-csrf-token" => Plug.CSRFProtection.get_csrf_token()}
end

GraphiQL stores the theme setting in localStorage under graphiql:settings, shared by every GraphiQL page on the same origin. A page with a forced :light or :dark theme writes that theme there, so a :system page on the same origin opened later starts with it until the user changes it in the settings dialog.

A Phoenix router calls init/1 at compile time, and an anonymous function cannot be stored in compiled code. In a router, use a {module, function} tuple or a captured named function such as &MyApp.GraphiQL.headers/1.

Subscriptions

Subscriptions use the Phoenix channel protocol of absinthe_phoenix. Set up the socket as described in the Absinthe subscription guide:

# lib/my_app_web/channels/user_socket.ex
defmodule MyAppWeb.UserSocket do
  use Phoenix.Socket
  use Absinthe.Phoenix.Socket, schema: MyAppWeb.Schema

  @impl true
  def connect(_params, socket, _connect_info), do: {:ok, socket}

  @impl true
  def id(_socket), do: nil
end

# lib/my_app_web/endpoint.ex
use Absinthe.Phoenix.Endpoint
socket "/socket", MyAppWeb.UserSocket, websocket: true

Then give the plug the socket path with socket_url: "/socket", or the module with socket: MyAppWeb.UserSocket.

How the IDE uses the socket:

Socket authentication

The headers editor does not apply to the socket. Pass credentials with :socket_params, which become the params of connect/3 in your socket:

forward "/graphiql", AbsintheGraphiQL.Plug,
  schema: MyAppWeb.Schema,
  socket_url: "/socket",
  socket_params: {MyAppWeb.GraphiQL, :socket_params}

defmodule MyAppWeb.GraphiQL do
  def socket_params(conn) do
    user = conn.assigns.current_user
    %{"token" => Phoenix.Token.sign(conn, "user socket", user.id)}
  end
end
def connect(%{"token" => token}, socket, _connect_info) do
  case Phoenix.Token.verify(socket, "user socket", token, max_age: 86_400) do
    {:ok, user_id} -> {:ok, Absinthe.Phoenix.Socket.put_options(socket, context: %{user_id: user_id})}
    {:error, _} -> :error
  end
end

The params are written into the page HTML, so anyone who can see the page can read them. Use short-lived tokens, and make sure the page itself requires the same login. The page is sent with cache-control: no-store.

Self-hosting and Content-Security-Policy

The bundle in priv/static holds GraphiQL, React, Monaco, its three web workers (editor, JSON and GraphQL), the Phoenix client, the CSS and the Roboto, Fira Code and codicon fonts. File names carry a content hash, and the plug serves them with cache-control: public, max-age=31536000, immutable. Unknown files get a 404. priv/static/THIRD_PARTY_LICENSES.txt lists the license of every bundled package and font.

The page has no inline scripts. The plug passes its configuration as HTML-escaped JSON in a data-config attribute. The default policy is:

default-src 'self'; script-src 'self'; worker-src 'self';
style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:;
connect-src 'self' ws://<host> wss://<host> <origins of absolute :default_url and :socket_url>;
base-uri 'none'; form-action 'none'; frame-ancestors 'self'

<host> is the host request header. Monaco adds <style> elements at run time, so style-src needs 'unsafe-inline'. Set csp: false when your app already sends its own policy for this path, or pass your own policy as a string.

The bundle is large because Monaco is large: about 8 MB in 66 files, about 2.3 MB gzipped. The page loads most of it on demand. Compress responses in your endpoint or proxy; Bandit does so by default.

Updating GraphiQL

The bundle is built from assets/ with esbuild. To update GraphiQL or another dependency:

  1. Change the exact versions in assets/package.json and run mise exec -- npm install in assets/ to update package-lock.json.
  2. Run mise exec -- mix assets.build. It runs npm ci and npm run build, which rewrites priv/static/, including manifest.json and THIRD_PARTY_LICENSES.txt.
  3. Run mise exec -- mix assets.test and mise exec -- mix test, then open the example app and check the IDE.
  4. Commit assets/package-lock.json and priv/static/.

mise.toml pins Erlang, Elixir and Node.js.

Example app

examples/dev_server.exs is a single-file Phoenix app with a query, a mutation and a subscription:

mise exec -- elixir examples/dev_server.exs

Open http://localhost:4401/graphiql, run the OnMessage subscription, and send a message from a terminal:

curl -s localhost:4401/graphiql -H 'content-type: application/json' \
  -d '{"query":"mutation { addMessage(input: {body: \"hi\"}) { id body } }"}'

Set THEME=light or THEME=dark to force a theme, and PORT to use another port.

Development

mise trust && mise install
mise exec -- mix deps.get
mise exec -- mix test
mise exec -- mix assets.test   # vitest, needs assets/node_modules (npm ci)
mise exec -- mix dialyzer

See CONTRIBUTING.md for the checks to run before a pull request and the Conventional Commits format that commit messages use.

License

MIT. See LICENSE. The bundled third-party software is listed in priv/static/THIRD_PARTY_LICENSES.txt.