import { Fetcher, fillLeafs, formatError, formatResult, GetDefaultFieldNamesFn, isAsyncIterable, isObservable, Unsubscribable, } from '@graphiql/toolkit'; import { ExecutionResult, GraphQLError, print } from 'graphql'; import { getFragmentDependenciesForAST } from 'graphql-language-service'; import setValue from 'set-value'; import getValue from 'get-value'; import type { StateCreator } from 'zustand'; import { tryParseJSONC, Range } from '../utility'; import type { SlicesWithActions, MonacoEditor } from '../types'; export interface ExecutionSlice { /** * If there is currently a GraphQL request in-flight. For multipart * requests like subscriptions, this will be `true` while fetching the * first partial response and `false` while fetching subsequent batches. * @default false */ isFetching: boolean; /** * Represents an active GraphQL subscription. * * For multipart operations such as subscriptions, this * will hold an `Unsubscribable` object while the request is in-flight. It * remains non-null until the operation completes or is manually unsubscribed. * * @remarks Use `subscription?.unsubscribe()` to cancel the request. * @default null */ subscription: Unsubscribable | null; /** * The operation name that will be sent with all GraphQL requests. * @default null */ overrideOperationName: string | null; /** * A function to determine which field leafs are automatically added when * trying to execute a query with missing selection sets. It will be called * with the `GraphQLType` for which fields need to be added. */ getDefaultFieldNames?: GetDefaultFieldNamesFn; /** * @default 0 */ queryId: number; /** * A function which accepts GraphQL HTTP parameters and returns a `Promise`, * `Observable` or `AsyncIterable` that returns the GraphQL response in * parsed JSON format. * * We suggest using the `createGraphiQLFetcher` utility from `@graphiql/toolkit` * to create these fetcher functions. * * @see {@link https://graphiql-test.netlify.app/typedoc/modules/graphiql_toolkit.html#creategraphiqlfetcher-2|`createGraphiQLFetcher`} */ fetcher: Fetcher; } export interface ExecutionActions { /** * Start a GraphQL request based on the current editor contents. */ run(): void; /** * Stop the GraphQL request that is currently in-flight. */ stop(): void; } export interface ExecutionProps extends Pick { /** * This prop sets the operation name that is passed with a GraphQL request. */ operationName?: string; } type CreateExecutionSlice = ( initial: Pick< ExecutionSlice, 'overrideOperationName' | 'getDefaultFieldNames' | 'fetcher' >, ) => StateCreator< SlicesWithActions, [], [], ExecutionSlice & { actions: ExecutionActions; } >; export const createExecutionSlice: CreateExecutionSlice = initial => (set, get) => { function getAutoCompleteLeafs() { const { queryEditor, schema, getDefaultFieldNames } = get(); if (!queryEditor) { return; } const query = queryEditor.getValue(); const { insertions, result = '' } = fillLeafs( schema, query, getDefaultFieldNames, ); if (!insertions.length) { return result; } const model = queryEditor.getModel()!; // Save the current cursor position as an offset const selection = queryEditor.getSelection()!; const cursorIndex = model.getOffsetAt(selection.getPosition()); // Replace entire content model.setValue(result); let added = 0; const decorations = insertions.map(({ index, string }) => { const start = model.getPositionAt(index + added); const end = model.getPositionAt(index + (added += string.length)); return { range: new Range( start.lineNumber, start.column, end.lineNumber, end.column, ), options: { className: 'auto-inserted-leaf', hoverMessage: { value: 'Automatically added leaf fields' }, isWholeLine: false, }, }; }); // Create a decoration collection (initially empty) const decorationCollection = queryEditor.createDecorationsCollection([]); // Apply decorations decorationCollection.set(decorations); // Clear decorations after 7 seconds setTimeout(() => { decorationCollection.clear(); }, 7000); // Adjust the cursor position based on insertions let newCursorIndex = cursorIndex; for (const { index, string } of insertions) { if (index < cursorIndex) { newCursorIndex += string.length; } } const newCursorPosition = model.getPositionAt(newCursorIndex); queryEditor.setPosition(newCursorPosition); return result; } return { ...initial, isFetching: false, subscription: null, queryId: 0, actions: { stop() { set(({ subscription }) => { subscription?.unsubscribe(); return { isFetching: false, subscription: null }; }); }, async run() { const { externalFragments, headerEditor, queryEditor, responseEditor, variableEditor, actions, operationName, documentAST, subscription, overrideOperationName, queryId, fetcher, } = get(); if (!queryEditor || !responseEditor) { return; } // If there's an active subscription, unsubscribe it and return if (subscription) { actions.stop(); return; } function setResponse(value: string): void { responseEditor?.setValue(value); actions.updateActiveTabValues({ response: value }); } function setError(error: Error, editor?: MonacoEditor): void { if (!editor) { return; } const name = editor === variableEditor ? 'Variables' : 'Request headers'; // Need to format since the response editor uses `json` language setResponse(formatError({ message: `${name} ${error.message}` })); } const newQueryId = queryId + 1; set({ queryId: newQueryId }); // Use the edited query after autoCompleteLeafs() runs or, // in case autoCompletion fails (the function returns undefined), // the current query from the editor. let query = getAutoCompleteLeafs() || queryEditor.getValue(); let variables: Record | undefined; try { variables = tryParseJSONC(variableEditor?.getValue()); } catch (error) { setError(error as Error, variableEditor); return; } let headers: Record | undefined; try { headers = tryParseJSONC(headerEditor?.getValue()); } catch (error) { setError(error as Error, headerEditor); return; } const fragmentDependencies = documentAST ? getFragmentDependenciesForAST(documentAST, externalFragments) : []; if (fragmentDependencies.length) { query += '\n' + fragmentDependencies.map(node => print(node)).join('\n'); } setResponse(''); set({ isFetching: true }); try { const fullResponse: ExecutionResult = {}; const handleResponse = (result: ExecutionResult) => { // A different query was dispatched in the meantime, so don't // show the results of this one. if (newQueryId !== get().queryId) { return; } let maybeMultipart = Array.isArray(result) ? result : false; if ( !maybeMultipart && typeof result === 'object' && 'hasNext' in result ) { maybeMultipart = [result]; } if (maybeMultipart) { for (const part of maybeMultipart) { mergeIncrementalResult(fullResponse, part); } set({ isFetching: false }); setResponse(formatResult(fullResponse)); } else { set({ isFetching: false }); setResponse(formatResult(result)); } }; const opName = overrideOperationName ?? operationName; const fetch = fetcher( { query, variables, operationName: opName }, { headers, documentAST }, ); const value = await fetch; if (isObservable(value)) { // If the fetcher returned an Observable, then subscribe to it, calling // the callback on each next value and handling both errors and the // completion of the Observable. const newSubscription = value.subscribe({ next(result) { handleResponse(result); }, error(error: Error) { set({ isFetching: false }); setResponse(formatError(error)); set({ subscription: null }); }, complete() { set({ isFetching: false, subscription: null }); }, }); set({ subscription: newSubscription }); } else if (isAsyncIterable(value)) { const newSubscription = { unsubscribe: () => value[Symbol.asyncIterator]().return?.(), }; set({ subscription: newSubscription }); for await (const result of value) { handleResponse(result); } set({ isFetching: false, subscription: null }); } else { handleResponse(value); } } catch (error) { set({ isFetching: false }); setResponse(formatError(error)); set({ subscription: null }); } }, }, }; }; interface IncrementalResult { data?: Record | null; errors?: ReadonlyArray; extensions?: Record; hasNext?: boolean; path?: ReadonlyArray; incremental?: ReadonlyArray; label?: string; items?: ReadonlyArray> | null; pending?: ReadonlyArray<{ id: string; path: ReadonlyArray }>; completed?: ReadonlyArray<{ id: string; errors?: ReadonlyArray; }>; id?: string; subPath?: ReadonlyArray; } const pathsMap = new WeakMap< ExecutionResult, Map> >(); /** * @param executionResult - The complete execution result object which will be * mutated by merging the contents of the incremental result. * @param incrementalResult - The incremental result that will be merged into the * complete execution result. */ function mergeIncrementalResult( executionResult: IncrementalResult, incrementalResult: IncrementalResult, ): void { let path: ReadonlyArray | undefined = [ 'data', ...(incrementalResult.path ?? []), ]; for (const result of [executionResult, incrementalResult]) { if (result.pending) { let paths = pathsMap.get(executionResult); if (paths === undefined) { paths = new Map(); pathsMap.set(executionResult, paths); } for (const { id, path: pendingPath } of result.pending) { paths.set(id, ['data', ...pendingPath]); } } } const { items, data, id } = incrementalResult; if (items) { if (id) { path = pathsMap.get(executionResult)?.get(id); if (path === undefined) { throw new Error('Invalid incremental delivery format.'); } const list = getValue(executionResult, path.join('.')); list.push(...items); } else { path = ['data', ...(incrementalResult.path ?? [])]; for (const item of items) { setValue(executionResult, path.join('.'), item); // Increment the last path segment (the array index) to merge the next item at the next index // @ts-expect-error -- (path[path.length - 1] as number)++ breaks react compiler path[path.length - 1]++; } } } if (data) { if (id) { path = pathsMap.get(executionResult)?.get(id); if (path === undefined) { throw new Error('Invalid incremental delivery format.'); } const { subPath } = incrementalResult; if (subPath !== undefined) { path = [...path, ...subPath]; } } setValue(executionResult, path.join('.'), data, { merge: true, }); } if (incrementalResult.errors) { executionResult.errors ||= []; (executionResult.errors as GraphQLError[]).push( ...incrementalResult.errors, ); } if (incrementalResult.extensions) { setValue(executionResult, 'extensions', incrementalResult.extensions, { merge: true, }); } if (incrementalResult.incremental) { for (const incrementalSubResult of incrementalResult.incremental) { mergeIncrementalResult(executionResult, incrementalSubResult); } } if (incrementalResult.completed) { // Remove tracking and add additional errors for (const { id: completedId, errors } of incrementalResult.completed) { pathsMap.get(executionResult)?.delete(completedId); if (errors) { executionResult.errors ||= []; (executionResult.errors as GraphQLError[]).push(...errors); } } } }