import { FetcherOpts, fetcherReturnToPromise, formatError, formatResult, isPromise, } from '@graphiql/toolkit'; import { buildClientSchema, getIntrospectionQuery, GraphQLError, GraphQLSchema, IntrospectionQuery, } from 'graphql'; import type { Dispatch } from 'react'; import type { StateCreator } from 'zustand'; import type { SlicesWithActions, SchemaReference } from '../types'; import { tryParseJSONC } from '../utility'; type MaybeGraphQLSchema = GraphQLSchema | null | undefined; type CreateSchemaSlice = ( initial: Pick< SchemaSlice, | 'inputValueDeprecation' | 'introspectionQueryName' | 'onSchemaChange' | 'schemaDescription' >, ) => StateCreator< SlicesWithActions, [], [], SchemaSlice & { actions: SchemaActions; } >; export const createSchemaSlice: CreateSchemaSlice = initial => (set, get) => ({ ...initial, fetchError: null, isIntrospecting: false, schema: null, /** * Derive validation errors from the schema */ validationErrors: [], schemaReference: null, requestCounter: 0, shouldIntrospect: true, actions: { setSchemaReference(schemaReference) { set({ schemaReference }); }, async introspect() { const { requestCounter, shouldIntrospect, onSchemaChange, headerEditor, fetcher, inputValueDeprecation, introspectionQueryName, schemaDescription, } = get(); /** * Only introspect if there is no schema provided via props. If the * prop is passed an introspection result, we do continue but skip the * introspection request. */ if (!shouldIntrospect) { return; } const counter = requestCounter + 1; set({ requestCounter: counter, isIntrospecting: true, fetchError: null }); try { let headers: Record | undefined; try { headers = tryParseJSONC(headerEditor?.getValue()); } catch (error) { throw new Error( `Introspection failed. Request headers ${error instanceof Error ? error.message : error}`, ); } const fetcherOpts: FetcherOpts = headers ? { headers } : {}; /** * Get an introspection query for settings given via props */ const introspectionQuery = getIntrospectionQuery({ inputValueDeprecation, schemaDescription, }); function doIntrospection(query: string) { const fetch = fetcherReturnToPromise( fetcher( { query, operationName: introspectionQueryName }, fetcherOpts, ), ); if (!isPromise(fetch)) { throw new TypeError( 'Fetcher did not return a Promise for introspection.', ); } return fetch; } const normalizedQuery = introspectionQueryName === 'IntrospectionQuery' ? introspectionQuery : introspectionQuery.replace( 'query IntrospectionQuery', `query ${introspectionQueryName}`, ); let result = await doIntrospection(normalizedQuery); if (typeof result !== 'object' || !('data' in result)) { // Try the stock introspection query first, falling back on the // sans-subscriptions query for services which do not yet support it. result = await doIntrospection( introspectionQuery.replace('subscriptionType { name }', ''), ); } set({ isIntrospecting: false }); let introspectionData: IntrospectionQuery | undefined; if (result.data && '__schema' in result.data) { introspectionData = result.data as IntrospectionQuery; } else { // handle as if it were an error if the fetcher response is not a string or response.data is not present const responseString = typeof result === 'string' ? result : formatResult(result); set({ fetchError: responseString }); } /** * Don't continue if another introspection request has been started in * the meantime or if there is no introspection data. */ if (counter !== get().requestCounter || !introspectionData) { return; } const newSchema = buildClientSchema(introspectionData); set({ schema: newSchema }); onSchemaChange?.(newSchema); } catch (error) { /** * Don't continue if another introspection request has been started in * the meantime. */ if (counter !== get().requestCounter) { return; } if (error instanceof Error) { delete error.stack; } set({ isIntrospecting: false, fetchError: formatError(error), }); } }, }, }); export interface SchemaSlice extends Pick< SchemaProps, | 'inputValueDeprecation' | 'introspectionQueryName' | 'schemaDescription' | 'onSchemaChange' > { /** * Stores an error raised during introspecting or building the GraphQL schema * from the introspection result. */ fetchError: string | null; /** * If there currently is an introspection request in-flight. */ isIntrospecting: boolean; /** * The current GraphQL schema. */ schema: MaybeGraphQLSchema; /** * A list of errors from validating the current GraphQL schema. The schema is * valid if and only if this list is empty. */ validationErrors: readonly GraphQLError[]; /** * The last type selected by the user. */ schemaReference: SchemaReference | null; /** * A counter that is incremented each time introspection is triggered or the * schema state is updated. */ requestCounter: number; /** * `false` when `schema` is provided via `props` as `GraphQLSchema | null` */ shouldIntrospect: boolean; } export interface SchemaActions { /** * Trigger building the GraphQL schema. This might trigger an introspection * request if no schema is passed via props and if using a schema is not * explicitly disabled by passing `null` as value for the `schema` prop. If * there is a schema (either fetched using introspection or passed via props) * it will be validated, unless this is explicitly skipped using the * `dangerouslyAssumeSchemaIsValid` prop. */ introspect(): Promise; /** * Set the current selected type. */ setSchemaReference: Dispatch; } export interface SchemaProps { /** * This prop can be used to skip validating the GraphQL schema. This applies * to both schemas fetched via introspection and schemas explicitly passed * via the `schema` prop. * * IMPORTANT NOTE: Without validating the schema, GraphiQL and its components * are vulnerable to numerous exploits and might break. Only use this prop if * you have full control over the schema passed to GraphiQL. * * @default false */ dangerouslyAssumeSchemaIsValid?: boolean; /** * Invoked after a new GraphQL schema was built. This includes both fetching * the schema via introspection and passing the schema using the `schema` * prop. * @param schema - The GraphQL schema that is now used for GraphiQL. */ onSchemaChange?(schema: GraphQLSchema): void; /** * Explicitly provide the GraphiQL schema that shall be used for GraphiQL. * If this props is... * - ...passed and the value is a GraphQL schema, it will be validated and * then used for GraphiQL if it is valid. * - ...passed and the value is the result of an introspection query, a * GraphQL schema will be built from this introspection data, it will be * validated, and then used for GraphiQL if it is valid. * - ...set to `null`, no introspection request will be triggered and * GraphiQL will run without a schema. * - ...set to `undefined` or not set at all, an introspection request will * be triggered. If this request succeeds, a GraphQL schema will be built * from the returned introspection data, it will be validated, and then * used for GraphiQL if it is valid. If this request fails, GraphiQL will * run without a schema. */ schema?: GraphQLSchema | IntrospectionQuery | null; /** * Can be used to set the equally named option for introspecting a GraphQL * server. * @default false * @see {@link https://github.com/graphql/graphql-js/blob/main/src/utilities/getIntrospectionQuery.ts|Utility for creating the introspection query} */ inputValueDeprecation?: boolean; /** * Can be used to set a custom operation name for the introspection query. * @default 'IntrospectionQuery' */ introspectionQueryName?: string; /** * Can be used to set the equally named option for introspecting a GraphQL * server. * @default false * @see {@link https://github.com/graphql/graphql-js/blob/main/src/utilities/getIntrospectionQuery.ts|Utility for creating the introspection query} */ schemaDescription?: boolean; }