|
| 1 | +import { |
| 2 | + buildSchema, |
| 3 | + GraphQLError, |
| 4 | + GraphQLSchema, |
| 5 | + parse, |
| 6 | + validate, |
| 7 | + type DocumentNode, |
| 8 | +} from 'graphql'; |
| 9 | +import { z } from 'zod'; |
| 10 | + |
| 11 | +export const graphqlRequestSchema = z.object({ |
| 12 | + query: z.string(), |
| 13 | + variables: z.record(z.string(), z.unknown()).optional(), |
| 14 | +}); |
| 15 | + |
| 16 | +export const graphqlResponseSuccessSchema = z.object({ |
| 17 | + data: z.record(z.string(), z.unknown()), |
| 18 | + errors: z.undefined(), |
| 19 | +}); |
| 20 | + |
| 21 | +export const graphqlErrorSchema = z.object({ |
| 22 | + message: z.string(), |
| 23 | + locations: z.array( |
| 24 | + z.object({ |
| 25 | + line: z.number(), |
| 26 | + column: z.number(), |
| 27 | + }) |
| 28 | + ), |
| 29 | +}); |
| 30 | + |
| 31 | +export const graphqlResponseErrorSchema = z.object({ |
| 32 | + data: z.undefined(), |
| 33 | + errors: z.array(graphqlErrorSchema), |
| 34 | +}); |
| 35 | + |
| 36 | +export const graphqlResponseSchema = z.union([ |
| 37 | + graphqlResponseSuccessSchema, |
| 38 | + graphqlResponseErrorSchema, |
| 39 | +]); |
| 40 | + |
| 41 | +export type GraphQLRequest = z.infer<typeof graphqlRequestSchema>; |
| 42 | +export type GraphQLResponse = z.infer<typeof graphqlResponseSchema>; |
| 43 | + |
| 44 | +export type QueryFn = ( |
| 45 | + request: GraphQLRequest |
| 46 | +) => Promise<Record<string, unknown>>; |
| 47 | + |
| 48 | +export type QueryOptions = { |
| 49 | + validateSchema?: boolean; |
| 50 | +}; |
| 51 | + |
| 52 | +export type GraphQLClientOptions = { |
| 53 | + /** |
| 54 | + * The URL of the GraphQL endpoint. |
| 55 | + */ |
| 56 | + url: string; |
| 57 | + |
| 58 | + /** |
| 59 | + * A function that loads the GraphQL schema. |
| 60 | + * This will be used for validating future queries. |
| 61 | + * |
| 62 | + * A `query` function is provided that can be used to |
| 63 | + * execute GraphQL queries against the endpoint |
| 64 | + * (e.g. if the API itself allows querying the schema). |
| 65 | + */ |
| 66 | + loadSchema?({ query }: { query: QueryFn }): Promise<string>; |
| 67 | + |
| 68 | + /** |
| 69 | + * Optional headers to include in the request. |
| 70 | + */ |
| 71 | + headers?: Record<string, string>; |
| 72 | +}; |
| 73 | + |
| 74 | +export class GraphQLClient { |
| 75 | + #url: string; |
| 76 | + #headers: Record<string, string>; |
| 77 | + |
| 78 | + /** |
| 79 | + * A promise that resolves when the schema is loaded via |
| 80 | + * the `loadSchema` function. |
| 81 | + * |
| 82 | + * Resolves to an object containing the raw schema source |
| 83 | + * string and the parsed GraphQL schema. |
| 84 | + * |
| 85 | + * Rejects if no `loadSchema` function was provided to |
| 86 | + * the constructor. |
| 87 | + */ |
| 88 | + schemaLoaded: Promise<{ |
| 89 | + /** |
| 90 | + * The raw GraphQL schema string. |
| 91 | + */ |
| 92 | + source: string; |
| 93 | + |
| 94 | + /** |
| 95 | + * The parsed GraphQL schema. |
| 96 | + */ |
| 97 | + schema: GraphQLSchema; |
| 98 | + }>; |
| 99 | + |
| 100 | + /** |
| 101 | + * Creates a new GraphQL client. |
| 102 | + */ |
| 103 | + constructor(options: GraphQLClientOptions) { |
| 104 | + this.#url = options.url; |
| 105 | + this.#headers = options.headers ?? {}; |
| 106 | + |
| 107 | + this.schemaLoaded = |
| 108 | + options |
| 109 | + .loadSchema?.({ query: this.#query.bind(this) }) |
| 110 | + .then((source) => ({ |
| 111 | + source, |
| 112 | + schema: buildSchema(source), |
| 113 | + })) ?? Promise.reject(new Error('No schema loader provided')); |
| 114 | + |
| 115 | + // Prevent unhandled promise rejections |
| 116 | + this.schemaLoaded.catch(() => {}); |
| 117 | + } |
| 118 | + |
| 119 | + /** |
| 120 | + * Executes a GraphQL query against the provided URL. |
| 121 | + */ |
| 122 | + async query( |
| 123 | + request: GraphQLRequest, |
| 124 | + options: QueryOptions = { validateSchema: true } |
| 125 | + ) { |
| 126 | + try { |
| 127 | + // Check that this is a valid GraphQL query |
| 128 | + const documentNode = parse(request.query); |
| 129 | + |
| 130 | + // Validate the query against the schema if requested |
| 131 | + if (options.validateSchema) { |
| 132 | + const { schema } = await this.schemaLoaded; |
| 133 | + const errors = validate(schema, documentNode); |
| 134 | + if (errors.length > 0) { |
| 135 | + throw new Error( |
| 136 | + `Invalid GraphQL query: ${errors.map((e) => e.message).join(', ')}` |
| 137 | + ); |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + return this.#query(request); |
| 142 | + } catch (error) { |
| 143 | + // Make it obvious that this is a GraphQL error |
| 144 | + if (error instanceof GraphQLError) { |
| 145 | + throw new Error(`Invalid GraphQL query: ${error.message}`); |
| 146 | + } |
| 147 | + |
| 148 | + throw error; |
| 149 | + } |
| 150 | + } |
| 151 | + |
| 152 | + /** |
| 153 | + * Executes a GraphQL query against the provided URL. |
| 154 | + * |
| 155 | + * Does not validate the query against the schema. |
| 156 | + */ |
| 157 | + async #query(request: GraphQLRequest) { |
| 158 | + const { query, variables } = request; |
| 159 | + |
| 160 | + const response = await fetch(this.#url, { |
| 161 | + method: 'POST', |
| 162 | + headers: { |
| 163 | + ...this.#headers, |
| 164 | + 'Content-Type': 'application/json', |
| 165 | + Accept: 'application/json', |
| 166 | + }, |
| 167 | + body: JSON.stringify({ |
| 168 | + query, |
| 169 | + variables, |
| 170 | + }), |
| 171 | + }); |
| 172 | + |
| 173 | + if (!response.ok) { |
| 174 | + throw new Error( |
| 175 | + `Failed to fetch Supabase Content API GraphQL schema: HTTP status ${response.status}` |
| 176 | + ); |
| 177 | + } |
| 178 | + |
| 179 | + const json = await response.json(); |
| 180 | + |
| 181 | + const { data, error } = graphqlResponseSchema.safeParse(json); |
| 182 | + |
| 183 | + if (error) { |
| 184 | + throw new Error( |
| 185 | + `Failed to parse Supabase Content API response: ${error.message}` |
| 186 | + ); |
| 187 | + } |
| 188 | + |
| 189 | + if (data.errors) { |
| 190 | + throw new Error( |
| 191 | + `Supabase Content API GraphQL error: ${data.errors |
| 192 | + .map( |
| 193 | + (err) => |
| 194 | + `${err.message} (line ${err.locations[0]?.line ?? 'unknown'}, column ${err.locations[0]?.column ?? 'unknown'})` |
| 195 | + ) |
| 196 | + .join(', ')}` |
| 197 | + ); |
| 198 | + } |
| 199 | + |
| 200 | + return data.data; |
| 201 | + } |
| 202 | +} |
| 203 | + |
| 204 | +/** |
| 205 | + * Extracts the fields from a GraphQL query document. |
| 206 | + */ |
| 207 | +export function getQueryFields(document: DocumentNode) { |
| 208 | + return document.definitions |
| 209 | + .filter((def) => def.kind === 'OperationDefinition') |
| 210 | + .flatMap((def) => { |
| 211 | + if (def.kind === 'OperationDefinition' && def.selectionSet) { |
| 212 | + return def.selectionSet.selections |
| 213 | + .filter((sel) => sel.kind === 'Field') |
| 214 | + .map((sel) => { |
| 215 | + if (sel.kind === 'Field') { |
| 216 | + return sel.name.value; |
| 217 | + } |
| 218 | + return null; |
| 219 | + }) |
| 220 | + .filter(Boolean); |
| 221 | + } |
| 222 | + return []; |
| 223 | + }); |
| 224 | +} |
0 commit comments