graphql.ts•5.63 kB
import {
buildSchema,
GraphQLError,
GraphQLSchema,
parse,
validate,
type DocumentNode,
} from 'graphql';
import { z } from 'zod';
export const graphqlRequestSchema = z.object({
query: z.string(),
variables: z.record(z.string(), z.unknown()).optional(),
});
export const graphqlResponseSuccessSchema = z.object({
data: z.record(z.string(), z.unknown()),
errors: z.undefined(),
});
export const graphqlErrorSchema = z.object({
message: z.string(),
locations: z.array(
z.object({
line: z.number(),
column: z.number(),
})
),
});
export const graphqlResponseErrorSchema = z.object({
data: z.undefined(),
errors: z.array(graphqlErrorSchema),
});
export const graphqlResponseSchema = z.union([
graphqlResponseSuccessSchema,
graphqlResponseErrorSchema,
]);
export type GraphQLRequest = z.infer<typeof graphqlRequestSchema>;
export type GraphQLResponse = z.infer<typeof graphqlResponseSchema>;
export type QueryFn = (
request: GraphQLRequest
) => Promise<Record<string, unknown>>;
export type QueryOptions = {
validateSchema?: boolean;
};
export type GraphQLClientOptions = {
/**
* The URL of the GraphQL endpoint.
*/
url: string;
/**
* A function that loads the GraphQL schema.
* This will be used for validating future queries.
*
* A `query` function is provided that can be used to
* execute GraphQL queries against the endpoint
* (e.g. if the API itself allows querying the schema).
*/
loadSchema?({ query }: { query: QueryFn }): Promise<string>;
/**
* Optional headers to include in the request.
*/
headers?: Record<string, string>;
};
export class GraphQLClient {
#url: string;
#headers: Record<string, string>;
/**
* A promise that resolves when the schema is loaded via
* the `loadSchema` function.
*
* Resolves to an object containing the raw schema source
* string and the parsed GraphQL schema.
*
* Rejects if no `loadSchema` function was provided to
* the constructor.
*/
schemaLoaded: Promise<{
/**
* The raw GraphQL schema string.
*/
source: string;
/**
* The parsed GraphQL schema.
*/
schema: GraphQLSchema;
}>;
/**
* Creates a new GraphQL client.
*/
constructor(options: GraphQLClientOptions) {
this.#url = options.url;
this.#headers = options.headers ?? {};
this.schemaLoaded =
options
.loadSchema?.({ query: this.#query.bind(this) })
.then((source) => ({
source,
schema: buildSchema(source),
})) ?? Promise.reject(new Error('No schema loader provided'));
// Prevent unhandled promise rejections
this.schemaLoaded.catch(() => {});
}
/**
* Executes a GraphQL query against the provided URL.
*/
async query(
request: GraphQLRequest,
options: QueryOptions = { validateSchema: true }
) {
try {
// Check that this is a valid GraphQL query
const documentNode = parse(request.query);
// Validate the query against the schema if requested
if (options.validateSchema) {
const { schema } = await this.schemaLoaded;
const errors = validate(schema, documentNode);
if (errors.length > 0) {
throw new Error(
`Invalid GraphQL query: ${errors.map((e) => e.message).join(', ')}`
);
}
}
return this.#query(request);
} catch (error) {
// Make it obvious that this is a GraphQL error
if (error instanceof GraphQLError) {
throw new Error(`Invalid GraphQL query: ${error.message}`);
}
throw error;
}
}
/**
* Sets the User-Agent header for all requests.
*/
setUserAgent(userAgent: string) {
this.#headers['User-Agent'] = userAgent;
}
/**
* Executes a GraphQL query against the provided URL.
*
* Does not validate the query against the schema.
*/
async #query(request: GraphQLRequest) {
const { query, variables } = request;
const response = await fetch(this.#url, {
method: 'POST',
headers: {
...this.#headers,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
query,
variables,
}),
});
if (!response.ok) {
throw new Error(
`Failed to fetch Supabase Content API GraphQL schema: HTTP status ${response.status}`
);
}
const json = await response.json();
const { data, error } = graphqlResponseSchema.safeParse(json);
if (error) {
throw new Error(
`Failed to parse Supabase Content API response: ${error.message}`
);
}
if (data.errors) {
throw new Error(
`Supabase Content API GraphQL error: ${data.errors
.map(
(err) =>
`${err.message} (line ${err.locations[0]?.line ?? 'unknown'}, column ${err.locations[0]?.column ?? 'unknown'})`
)
.join(', ')}`
);
}
return data.data;
}
}
/**
* Extracts the fields from a GraphQL query document.
*/
export function getQueryFields(document: DocumentNode) {
return document.definitions
.filter((def) => def.kind === 'OperationDefinition')
.flatMap((def) => {
if (def.kind === 'OperationDefinition' && def.selectionSet) {
return def.selectionSet.selections
.filter((sel) => sel.kind === 'Field')
.map((sel) => {
if (sel.kind === 'Field') {
return sel.name.value;
}
return null;
})
.filter(Boolean);
}
return [];
});
}