Gaiia

Hex.pmDocumentationLicenseCI

An Elixir client for the Gaiia GraphQL API.

Write GraphQL by hand against a small Req-based client, or call one of the functions generated from the schema — 125 queries and 212 mutations, one function each, with the API's own descriptions and argument types carried through as @doc.

Unofficial community project. Not affiliated with, endorsed by, or supported by Gaiia.

Installation

Add gaiia to your dependencies in mix.exs:

def deps do
[
{:gaiia, "~> 0.2.0"}
]
end

Requires Elixir ~> 1.19. Documentation is on HexDocs.

Configuration

config :gaiia,
endpoint: "https://api.gaiia.com/api/v1",
api_key: System.get_env("GAIIA_API_KEY")

The endpoint above is the default, so only the key is required. It is sent as X-Gaiia-Api-Key: <key>, the only scheme the API accepts — an Authorization header authenticates nothing. Omit the key and every operation comes back as an UNAUTHENTICATED GraphQL error. Keys are issued in Admin -> API keys with per-key permissions; gaiia_sk_* keys are server-side secrets, gaiia_pk_* are publishable, and *_sbox_* keys address the sandbox instance. Never commit a key — read it from the environment.

With that config in place, the top-level module builds an implicit client:

Gaiia.query("query Account($id: GlobalID!) { account(id: $id) { id name } }", %{"id" => id})
#=> {:ok, %{"account" => %{"id" => "...", "name" => "..."}}}
Gaiia.mutate("mutation ...", %{"input" => input})

Explicit clients

For multiple endpoints, per-call headers, or request options, build a Gaiia.Client yourself. Clients are immutable structs, safe to build once and share.

client = Gaiia.Client.new(api_key: key, headers: [{"x-trace-id", "abc"}])
Gaiia.Client.query(client, "{ __typename }")

Gaiia.Client.new/1 accepts:

OptionMeaning
:endpointGraphQL endpoint URL. Defaults to https://api.gaiia.com/api/v1.
:api_keyAPI key for the X-Gaiia-Api-Key header.
:timezoneIANA identifier sent as x-timezone.
:headersExtra headers as [{name, value}].
:req_optionsOptions forwarded to Req.request/1 (:adapter, :plug, ...).

Generated operations

Every query and mutation in the schema has a generated function. Names are Macro.underscored from the GraphQL field, and the result is unwrapped from the data envelope:

Gaiia.Queries.account(client, %{"id" => id}, "id name")
#=> {:ok, %{"id" => "...", "name" => "..."}}
Gaiia.Mutations.create_internal_ticket(client, %{"input" => input}, "ticket { id }")

The signature is the same throughout:

name(client, variables \\ %{}, selection \\ "", opts \\ [])

Operations the schema marks deprecated carry Elixir's @deprecated, so calling one warns at compile time and names its replacement.

Schema reference

Each generated function documents its own arguments — every name, its rendered GraphQL type, and any default — so h Gaiia.Queries.account or HexDocs answers what a call takes. What it does not answer is what to put in selection, since that depends on the return type.

The type side of the schema lives in the repository under docs/, written by the same introspection task: objects.json, inputs.json, enums.json, unions.json, interfaces.json, and scalars.json, each field and input rendered in GraphQL syntax with the API's own description. They are reference dumps rather than a compile-time input, so they are not shipped in the Hex package — read them in the repository, alongside Gaiia's own docs at https://app.gaiia.com/docs.

Pagination

Gaiia.Pagination.stream/4 walks Relay-style cursors lazily, so only the pages you consume are fetched. edges/4 is the same walk over edges, when you need each item's own cursor.

query = ~S"""
query Accounts($first: Int, $after: String) {
accounts(first: $first, after: $after) {
nodes { id name }
pageInfo { hasNextPage endCursor }
}
}
"""
client
|> Gaiia.Pagination.stream(query, %{"first" => 50}, path: ["accounts"])
|> Stream.take(200)
|> Enum.to_list()

:path locates the connection inside data. :direction selects :forward (first/after, following hasNextPage/endCursor) or :backward (last/before, following hasPreviousPage/startCursor); the cursor variable defaults to match and :cursor_variable renames it. Pages default to 50 and cap at 250, but individual fields override both, so the library does not enforce a size.

A request failure mid-stream raises the Gaiia.Error, since a stream cannot return an error tuple. When the whole collection is wanted anyway and the failure belongs in a return value, use the eager pair instead:

{:ok, accounts} =
Gaiia.Pagination.collect(client, query, %{"first" => 50}, path: ["accounts"])

collect/4 and collect_edges/4 take the same options, stop at the first failed page, and return {:ok, items} | {:error, %Gaiia.Error{}}.

Rate limits

Limits are query-cost based: each key has a point bucket that every operation draws from. Gaiia.Client.request/4 returns a Gaiia.Response instead of bare data, carrying the reported budget so bulk jobs can throttle before being rejected:

{:ok, %Gaiia.Response{data: data, rate_limit: %Gaiia.RateLimit{remaining: remaining}}} =
Gaiia.Client.request(client, "{ accounts(first: 50) { nodes { id } } }")

A rejected operation is a RATE_LIMITED GraphQL error; the same struct is then on the error, including retry_at.

Gaiia.RateLimit.exhausted?/1 answers whether the budget leaves room for another operation, and retry_after/2 turns retry_at into whole seconds to wait (nil when the API named no time, so the caller picks its own backoff).

Global IDs

Gaiia.GlobalID converts between the API's type_base58 global IDs and UUIDs. Multi-word types keep their underscores (work_order_6fRnaKy8Xf1Vsh2Wz2sVnR).

Gaiia.GlobalID.encode("Account", uuid)
Gaiia.GlobalID.decode(global_id) #=> {"account", uuid}
Gaiia.GlobalID.type(global_id)
Gaiia.GlobalID.to_uuid(global_id)

Files

File bytes move directly between you and Gaiia's storage host, outside GraphQL. Gaiia.Files covers those transfers; the surrounding mutations are generated like any other.

{:ok, %{"uploadUrl" => %{"url" => url, "fileKey" => key}}} =
Gaiia.Mutations.create_document_upload_url(client, %{"input" => input}, "uploadUrl { url fileKey }")
:ok = Gaiia.Files.upload(url, File.read!("contract.pdf"), "application/pdf")
Gaiia.Mutations.create_document(client, %{"input" => %{"fileKey" => key}}, "document { id }")

Download URLs come from any File object's url field and expire, so re-run the query for a fresh one. Gaiia.Files.download/2 returns the bytes and download_to/3 streams to disk. Neither sends your API key to the file host.

Webhooks

Gaiia signs each delivery with X-Gaiia-Webhook-Signature. Gaiia.Webhook.verify/4 checks it against the raw request body — the exact bytes, since re-encoding the JSON changes the signature:

case Gaiia.Webhook.verify(raw_body, signature_header, secret) do
:ok -> handle(event)
{:error, reason} -> send_resp(conn, 400, to_string(reason))
end

The header is t=<unix timestamp>,v1=<hex digest>, and Gaiia stamps t= in milliseconds. verify/4 infers that from the value's magnitude, so nothing is needed for a live delivery; pass unit: :second or unit: :millisecond to reject the other scale, and tolerance:/now: (both in seconds) to control the replay window.

Manage endpoints and subscriptions with Gaiia.Queries.webhooks/4 and Gaiia.Mutations.create_webhook/4.

Errors

Failures come back as {:error, %Gaiia.Error{kind: kind}}, where kind lets you match the failure mode instead of parsing messages:

:kindCause
:graphql2xx response carrying a non-empty errors list
:httpNon-2xx HTTP response (:status, :details hold it)
:networkRequest never reached the server — DNS, refused, timeout
:decode2xx body that was not a valid GraphQL envelope

Most Gaiia failures are :graphql at HTTP 200. :code carries the first error's extensions.code ("UNAUTHENTICATED", "RATE_LIMITED", ...). Expected mutation failures are not errors at all: they arrive as an errors list inside the mutation payload of an {:ok, data} result.

Gaiia.Error.retriable?/1 separates the failures worth trying again (transport error, 5xx, rate limiting in either form) from the ones that will fail identically, and retry_after/2 reports the wait the API asked for:

with {:error, error} <- Gaiia.Queries.accounts(client, %{"first" => 50}, "nodes { id }") do
if Gaiia.Error.retriable?(error) do
{:snooze, Gaiia.Error.retry_after(error) || 30}
else
{:cancel, Exception.message(error)}
end
end

Gaiia.Error is an exception, so it can also be raised or passed to Exception.message/1.

Testing against the client

Pass a Req adapter through :req_options to intercept requests without network access — this is how the suite tests header and body construction; see test/support/req_stub.ex.

client = Gaiia.Client.new(endpoint: endpoint, req_options: [adapter: MyStub])

Development

mix deps.get
mix test
mix format
mix credo --strict
mix dialyzer
mix docs --warnings-as-errors

CI runs mix test on Elixir 1.20.4 / Erlang-OTP 29.0.5, and builds the docs and the Hex package on the same versions. mise.toml pins those two versions locally, so mise install provisions exactly what CI uses; the library itself supports Elixir ~> 1.19 and is not pinned to them.

Formatting runs Styler as a plugin, so mix format also normalizes aliases and directive order.

Refreshing the schema

Gaiia.Queries and Gaiia.Mutations are generated at compile time from priv/queries.json and priv/mutations.json, so those dumps are the API surface — an operation missing from them has no function. Refresh them from a live introspection query:

GAIIA_API_KEY=... mix gaiia.introspect
mix compile --force

The task also rewrites the flattened, human-readable dumps under docs/ — every object, input object, enum, union, interface, and scalar in the schema, with types rendered in GraphQL syntax. mix gaiia.introspect --check writes nothing and fails when the bundled dumps have drifted from the live schema.

Releasing

Releases go to Hex from a clean tree on main. @version in mix.exs drives the package version, the docs source_ref, and the changelog link in the package metadata, so the tag has to name the commit being published — a tag pointing elsewhere sends every source link in the docs to the wrong tree.

# 1. Bump @version in mix.exs and add the CHANGELOG.md entry.
# 2. Verify.
mix test
mix format --check-formatted
mix credo --strict
mix docs --warnings-as-errors
mix hex.build # inspect the file list and metadata
# 3. Tag the release commit and publish.
version=$(mix run -e 'IO.puts(Mix.Project.config()[:version])')
git tag -a "v$version" -m "v$version"
git push origin main --follow-tags
mix hex.publish # publishes the package and the docs

mix hex.publish needs a Hex account with the package owner's API key — mix hex.user auth locally, or HEX_API_KEY from mix hex.user key generate in CI.

License

MIT — see LICENSE.