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 with the Monaco editor, the documentation explorer, the query builder, history and collections.
- Every script, web worker, stylesheet and font is in this package and served by the plug. The page makes no requests to other hosts and works without internet access.
- Subscriptions run over the Absinthe channel of your Phoenix socket
(
absinthe_phoenix). - A strict
content-security-policyheader by default.
GraphiQL 6 is a release candidate. This package bundles
graphiql@6.0.0-rc.1and its1.0.0-rccompanion 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:
- Queries and mutations always go over HTTP to
:default_url, with the headers from the headers editor. Cookies are sent to the same origin only. - A subscription joins
__absinthe__:control, pushes the document, and shows everysubscription:dataevent in the result pane. Validation errors from the server show there too. Stopping the operation in GraphiQL sendsunsubscribe. - When the document has several operations, the IDE sends only the selected one and
the fragments it uses, because the Absinthe channel ignores
operationName. - When the socket reconnects, the IDE sends every running subscription again. Events published while the socket was disconnected are lost.
- The socket connects when the first subscription starts, not when the page loads.
- A subscription sent to the HTTP endpoint gets an error, as with
Absinthe.Plug.GraphiQL.
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:
- Change the exact versions in
assets/package.jsonand runmise exec -- npm installinassets/to updatepackage-lock.json. - Run
mise exec -- mix assets.build. It runsnpm ciandnpm run build, which rewritespriv/static/, includingmanifest.jsonandTHIRD_PARTY_LICENSES.txt. - Run
mise exec -- mix assets.testandmise exec -- mix test, then open the example app and check the IDE. - Commit
assets/package-lock.jsonandpriv/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.