GraphQL Client
The GraphQL client provides typed queries and mutations to the Heimdall API.
graphqlRequest
Execute a GraphQL query or mutation, returning the full response including errors. The
first argument is always an ApiClientConfig — pass
getApiConfig() from @/lib/api.
import { getApiConfig, graphqlRequest } from "@/lib/api";
import type { GraphQLResponse } from "@/lib/api";
const response: GraphQLResponse<{ user: User }> = await graphqlRequest<{ user: User }>(
getApiConfig(),
query,
variables
);
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
config | ApiClientConfig | Yes | API client config (baseUrl, optional systemApiKey) — use getApiConfig() |
query | string | Yes | GraphQL query/mutation string |
variables | Record<string, unknown> | No | Query variables |
options | GraphQLRequestOptions | No | Request options |
Options
interface GraphQLRequestOptions {
/** OAuth access token for user context */
accessToken?: string;
/** Custom headers */
headers?: Record<string, string>;
/** Original User-Agent from the client browser (forwarded as X-Original-User-Agent) */
userAgent?: string;
/** Original IP address from the client (forwarded as X-Forwarded-For) */
clientIp?: string;
/** Source service making the request (e.g., "id", "backend"). Sent as X-Source-Service header. */
sourceService?: string;
/** Explicit opt-in to use the system API key (genuine no-user-context calls only). */
system?: boolean;
/**
* Genuinely public / pre-auth call — sends NO auth header at all (not even the
* system key). The server enforces what is public. Mutually exclusive with `system`.
*/
anonymous?: boolean;
}
Response
interface GraphQLResponse<T> {
data?: T;
errors?: Array<{ message: string; path?: string[] }>;
}
Example
const query = `
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`;
const response = await graphqlRequest<{ user: User }>(getApiConfig(), query, { id: "123" });
if (response.errors) {
console.error("GraphQL errors:", response.errors);
return;
}
console.log("User:", response.data?.user);
gql
Helper function that extracts data or throws on errors. Useful when you want exceptions instead of error handling. Like graphqlRequest, its first argument is the ApiClientConfig.
import { getApiConfig, gql } from "@/lib/api";
const data = await gql<{ user: User }>(getApiConfig(), query, variables);
// Throws if errors occur
Parameters
Same as graphqlRequest (config first, then query, variables, options).
Returns
Returns T directly (the data from the response).
Throws
Errorif the response contains GraphQL errorsErrorif no data is returned
Examples
Query
const query = `
query GetUsers($limit: Int!) {
users(limit: $limit) {
id
name
}
}
`;
try {
const { users } = await gql<{ users: User[] }>(getApiConfig(), query, { limit: 10 });
console.log("Users:", users);
} catch (error) {
console.error("Query failed:", error.message);
}
Mutation
const mutation = `
mutation UpdateUser($id: ID!, $name: String!) {
updateUser(id: $id, name: $name) {
id
name
}
}
`;
try {
const { updateUser } = await gql<{ updateUser: User }>(getApiConfig(), mutation, {
id: "123",
name: "New Name",
});
console.log("Updated user:", updateUser);
} catch (error) {
console.error("Mutation failed:", error.message);
}
With Access Token
const { user } = await gql<{ user: User }>(
getApiConfig(),
query,
{ id: "123" },
{ accessToken: session.accessToken }
);
Authentication (fail-closed)
graphqlRequest/gql never silently fall back to the system API key. Each call
must carry exactly one explicit auth signal; otherwise the transport throws
AuthRequiredError (mapped to
401 AUTH_REQUIRED by handleApiError):
accessToken— user Bearer token (preferred for user-scoped calls)system: true— system API key (requiresconfig.systemApiKey); genuine no-user-context calls onlyanonymous: true— NO auth header (genuinely public / pre-auth reads)
system and anonymous are mutually exclusive. If none of the above is set, the
call throws AuthRequiredError.
// User-scoped call
const response = await graphqlRequest(getApiConfig(), query, variables, {
accessToken: userToken,
});
// Service-to-service (no user context)
const response = await graphqlRequest(getApiConfig(), query, variables, {
system: true,
});
// Genuinely public pre-auth read (e.g. enabled login platforms)
const response = await graphqlRequest(getApiConfig(), query, variables, {
anonymous: true,
});
Error Handling
With graphqlRequest
const response = await graphqlRequest<{ user: User }>(getApiConfig(), query, variables);
if (response.errors) {
for (const error of response.errors) {
console.error(`Error at ${error.path?.join(".")}: ${error.message}`);
}
return;
}
// Safe to use response.data
console.log(response.data?.user);
With gql
try {
const data = await gql<{ user: User }>(getApiConfig(), query, variables);
console.log(data.user);
} catch (error) {
// Handle the first error message
console.error(error.message);
}
Client Info & Audit Logging
extractClientInfo
Extract client information from a Next.js request for audit logging.
import { getApiConfig, extractClientInfo } from "@/lib/api";
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const clientInfo = extractClientInfo(request);
// { userAgent: "...", clientIp: "..." }
const data = await gql<{ result: Result }>(
getApiConfig(),
mutation,
variables,
clientInfo // Passed as the options (4th) parameter
);
}
extractClientInfo takes no config — it only reads headers from the request.
ClientInfo Type
interface ClientInfo {
userAgent?: string;
clientIp?: string;
}
Source service
The library reads no SOURCE_SERVICE env var itself, but ApiClientConfig.sourceService
(built by each app's getApiConfig() from its own getSourceService() in
src/lib/config.ts) is sent as the X-Source-Service header on every graphqlRequest
/ gql call by default — this is what lets the Rust API attribute audit events (e.g. the
per-app login event emitted at OAuth token exchange) to "id", "backend", or
"policies" instead of the generic "api" fallback.
To override it for a single call (rare — e.g. a shared route that must attribute to a
different app than the one it runs in), pass sourceService in the request options; it
takes precedence over config.sourceService:
const data = await gql<{ result: Result }>(
getApiConfig(),
mutation,
variables,
{ accessToken, sourceService: "id" } // overrides config.sourceService for this call only
);
See Source & device attribution from webapps
for the full picture, including how the SSO login flow additionally forwards the real
browser X-Original-User-Agent on the server-to-server token exchange.
Complete API Route Example
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getApiConfig, gql, extractClientInfo } from "@/lib/api";
export async function POST(request: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const clientInfo = extractClientInfo(request);
const data = await gql<{ updateProfile: User }>(
getApiConfig(),
`mutation UpdateProfile($input: UpdateProfileInput!) {
updateProfile(input: $input) {
id
name
}
}`,
{ input: { userId: session.user.id, ...body } },
{ accessToken: session.accessToken, ...clientInfo }
);
return NextResponse.json(data.updateProfile);
}
Common Patterns
Fetching with Loading State
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchUser() {
try {
const { user } = await gql<{ user: User }>(
getApiConfig(),
`query { user(id: "${userId}") { id name email } }`
);
setUser(user);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}
fetchUser();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{user?.name}</div>;
}