diff --git a/package.json b/package.json index 06f7b2fb049..ecc6bd092e8 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "npm": "please_use_yarn_instead" }, "scripts": { - "build": "yarn build:clean && yarn build:cm6-graphql && yarn build:packages && yarn build:graphiql-react && yarn build:graphiql-plugin-explorer && yarn build:graphiql-plugin-code-exporter && yarn build:graphiql", + "build": "yarn build:clean && yarn build:cm6-graphql && yarn build:packages && yarn build:graphiql-react && yarn build:graphiql-plugin-explorer && yarn build:graphiql-plugin-code-exporter && yarn build:graphiql-plugin-batch-request && yarn build:graphiql", "build-bundles": "yarn prebuild-bundles && wsrun -p -m -s build-bundles", "build-bundles-clean": "rimraf '{packages,examples,plugins}/**/{bundle,cdn,webpack}' && yarn workspace graphiql run build-bundles-clean", "build-clean": "wsrun -m build-clean ", @@ -40,6 +40,7 @@ "build:graphiql": "yarn tsc resources/tsconfig.graphiql.json", "build:graphiql-plugin-explorer": "yarn workspace @graphiql/plugin-explorer run build", "build:graphiql-plugin-code-exporter": "yarn workspace @graphiql/plugin-code-exporter run build", + "build:graphiql-plugin-batch-request": "yarn workspace @graphiql/plugin-batch-request run build", "build:graphiql-react": "yarn workspace @graphiql/react run build", "build:packages": "yarn tsc", "build:watch": "yarn tsc --watch", diff --git a/packages/graphiql-plugin-batch-request/.eslintrc b/packages/graphiql-plugin-batch-request/.eslintrc new file mode 100644 index 00000000000..98f785433f6 --- /dev/null +++ b/packages/graphiql-plugin-batch-request/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": ["plugin:react/jsx-runtime"] +} diff --git a/packages/graphiql-plugin-batch-request/.gitignore b/packages/graphiql-plugin-batch-request/.gitignore new file mode 100644 index 00000000000..81275974cf0 --- /dev/null +++ b/packages/graphiql-plugin-batch-request/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +types +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/graphiql-plugin-batch-request/CHANGELOG.md b/packages/graphiql-plugin-batch-request/CHANGELOG.md new file mode 100644 index 00000000000..f28cf288d54 --- /dev/null +++ b/packages/graphiql-plugin-batch-request/CHANGELOG.md @@ -0,0 +1 @@ +# @graphiql/plugin-batch-request \ No newline at end of file diff --git a/packages/graphiql-plugin-batch-request/README.md b/packages/graphiql-plugin-batch-request/README.md new file mode 100644 index 00000000000..f86dc41b14e --- /dev/null +++ b/packages/graphiql-plugin-batch-request/README.md @@ -0,0 +1,132 @@ +# GraphiQL Batch Request Plugin + +This package provides a plugin that allows sending a batch request to a GraphQL Server and thence into the GraphiQI UI. + +The plugin scope is for sending multiple GraphQL using 2 main batching strategy: +1. Single operation. +2. Array of operations (GraphQL server must be configured to allow arrays). + +### Single operation +Combine multiple operations ad execute them as one. + +For example, given the following GraphQL operations: + +```graphql +query Query1($arg: String) { + field1 + field2(input: $arg) +} + +query Query2($arg: String) { + field2(input: $arg) + alias: field3 +} +``` + +These can be merged into one operation: + +```graphql +query ($_0_arg: String, $_1_arg: String) { + _0_field1: field1 + _0_field2: field2(input: $_0_arg) + _1_field2: field3(input: $_1_arg) + _1_alias: field3 +} +``` + +### Array of operations +Combine multiple GraphQL Requests and combine them into one GraphQL Request using an array, having the server recognize the request as an array of operations instead of a single one, and handle each operation separately. + +For example, given the following GraphQL Requests: + +```json +{ + "operationName": "Query1", + "query": "query Query1($arg: String) { ... }", + "variables": { + "arg": "foo" + } +} + +{ + "operationName": "Query2", + "query": "query Query2($arg: String) { ... }", + "variables": { + "arg": "foo" + } +} + +``` + +These can be merged into one GraphQL Array Request: + +```json +[ + { + "operationName": "Query1", + "query": "query Query1($arg: String) { ... }", + "variables": { + "arg": "foo" + } + }, + { + "operationName": "Query2", + "query": "query Query2($arg: String) { ... }", + "variables": { + "arg": "foo" + } + } +] +``` + +## Install + +Use your favorite package manager to install the package: + +```sh +npm i -S @graphiql/plugin-batch-request +``` + +The following packages are peer dependencies, so make sure you have them installed as well: + +```sh +npm i -S react react-dom graphql +``` + +## Usage + +```jsx +import { useBatchRequestPlugin } from '@graphiql/plugin-batch-request'; +import { createGraphiQLFetcher } from '@graphiql/toolkit'; +import { GraphiQL } from 'graphiql'; +import { useState } from 'react'; + +import 'graphiql/graphiql.css'; +import '@graphiql/plugin-batch-request/dist/style.css'; + +const url = 'https://countries.trevorblades.com/graphql'; + +const fetcher = createGraphiQLFetcher({ + url +}); + +function GraphiQLWithExplorer() { + const [query, setQuery] = useState(DEFAULT_QUERY); + const batchRequestPlugin = useBatchRequestPlugin({ url }); + return ( + + ); +} +``` + + +### Example + +Sending a batch request to the countries GraphQL server: + +https://user-images.githubusercontent.com/6611331/216736177-2d8d6153-b246-48ef-8e97-687beea6f9fc.mov \ No newline at end of file diff --git a/packages/graphiql-plugin-batch-request/index.html b/packages/graphiql-plugin-batch-request/index.html new file mode 100644 index 00000000000..6ca3d557708 --- /dev/null +++ b/packages/graphiql-plugin-batch-request/index.html @@ -0,0 +1,22 @@ + + + + + + + + +
+ + + diff --git a/packages/graphiql-plugin-batch-request/jest.config.js b/packages/graphiql-plugin-batch-request/jest.config.js new file mode 100644 index 00000000000..d5f2ae82f35 --- /dev/null +++ b/packages/graphiql-plugin-batch-request/jest.config.js @@ -0,0 +1,5 @@ +const base = require('../../jest.config.base')(__dirname); + +module.exports = { + ...base, +}; diff --git a/packages/graphiql-plugin-batch-request/package.json b/packages/graphiql-plugin-batch-request/package.json new file mode 100644 index 00000000000..25dd2e060eb --- /dev/null +++ b/packages/graphiql-plugin-batch-request/package.json @@ -0,0 +1,51 @@ +{ + "name": "@graphiql/plugin-batch-request", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "https://github.com/graphql/graphiql", + "directory": "packages/graphiql-plugin-batch-request" + }, + "main": "dist/graphiql-plugin-batch-request.cjs.js", + "module": "dist/graphiql-plugin-batch-request.es.js", + "types": "types/index.d.ts", + "license": "MIT", + "keywords": [ + "react", + "graphql", + "graphiql", + "plugin", + "batch-request" + ], + "files": [ + "dist", + "src", + "types" + ], + "scripts": { + "dev": "vite", + "build": "tsc --emitDeclarationOnly && node resources/copy-types.mjs && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@graphiql/react": "^0.15.0", + "@fortawesome/fontawesome-free": "6.2.1", + "@graphql-tools/utils": "9.1.4", + "react-checkbox-tree": "1.8.0" + }, + "peerDependencies": { + "graphql": "^15.5.0 || ^16.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^1.3.0", + "typescript": "^4.6.3", + "vite": "^2.9.13", + "graphql": "^16.4.0", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "@graphiql/toolkit": "^0.8.0", + "graphiql": "^2.2.0" + } +} diff --git a/packages/graphiql-plugin-batch-request/resources/copy-types.mjs b/packages/graphiql-plugin-batch-request/resources/copy-types.mjs new file mode 100644 index 00000000000..79f3f5a619d --- /dev/null +++ b/packages/graphiql-plugin-batch-request/resources/copy-types.mjs @@ -0,0 +1,11 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const base = path.resolve(path.dirname(__filename), '..'); + +fs.copyFileSync( + path.resolve(base, 'src', 'graphiql-batch-request.d.ts'), + path.resolve(base, 'types', 'graphiql-batch-request.d.ts'), +); diff --git a/packages/graphiql-plugin-batch-request/src/batch-request-plugin.tsx b/packages/graphiql-plugin-batch-request/src/batch-request-plugin.tsx new file mode 100644 index 00000000000..a1bded8d67d --- /dev/null +++ b/packages/graphiql-plugin-batch-request/src/batch-request-plugin.tsx @@ -0,0 +1,242 @@ +import { Button, ButtonGroup, PlayIcon, Spinner, StopIcon, useEditorContext } from '@graphiql/react'; +import { FetcherParamsWithDocument, GraphiQLBatchRequestProps, TabsWithOperations } from 'graphiql-batch-request'; +import { GraphQLError, parse, print } from 'graphql'; +import { Kind } from 'graphql/language'; +import { useState } from 'react'; +import CheckboxTree from 'react-checkbox-tree'; +import { filterDocumentByOperationName } from './filter-document'; +import { mergeRequests } from './merge-requests'; + +import "@fortawesome/fontawesome-free/css/all.css"; +import 'react-checkbox-tree/lib/react-checkbox-tree.css'; +import './graphiql-batch-request.d.ts'; +import './index.css'; +import { FetcherParams } from '@graphiql/toolkit'; + +enum BatchStrategy { ALIASES, ARRAY }; + +export function BatchRequestPlugin({ + url, + useAllOperations = false +}: GraphiQLBatchRequestProps) { + const { tabs, responseEditor } = useEditorContext({ nonNull: true }); + + let parsingError = ''; + let tabsWithOperations: TabsWithOperations = {}; + try { + tabsWithOperations = tabs + .filter(tab => tab.query !== '' && tab.query !== null) + .map(tab => { + const document = parse(tab.query as string); + return { + id: tab.id, + document, + operations: document.definitions.filter( + definition => definition.kind === Kind.OPERATION_DEFINITION + ), + variables: tab.variables && tab.variables.trim() !== '' + ? JSON.parse(tab.variables) : + undefined, + headers: tab.headers && tab.headers.trim() !== '' + ? JSON.parse(tab.headers) : + undefined + } + }) + .reduce((acc, tabWithOperations) => { + acc[tabWithOperations.id] = tabWithOperations; + return acc; + }, {} as any); + } catch(e: unknown) { + if (e instanceof GraphQLError || e instanceof SyntaxError) { + parsingError = e.message; + } + } + + const operationValues: string[] = []; + const nodes = Object.values(tabsWithOperations).map( + (tabWithOperations, i) => ({ + value: tabWithOperations.id, + label: `Tab ${i + 1}`, + children: tabWithOperations.operations.map((operation, j) => { + const operationValue = operation.name?.value + ? `${tabWithOperations.id}|${operation.name.value}` + : `${tabWithOperations.id}|${j}`; + operationValues.push(operationValue); + + return { + value: operationValue, + label: operation.name?.value ?? 'Unnamed operation' + } + }) + }) + ); + + const [batchingStrategy, setBatchingStrategy] = useState(BatchStrategy.ARRAY); + const [batchResponseLoading, setBatchResponseLoading] = useState(false); + const [executeButtonDisabled, setExecuteButtonDisabled] = useState( + useAllOperations === false + ); + const [selectedOperations, setSelectedOperations] = useState( + useAllOperations ? operationValues : [] + ); + const [expandedOperations, setExpandedOperations] = useState( + Object.keys(tabsWithOperations) + ); + + + if (parsingError !== '') { + return ( + <> +

Error parsing queries, verify your queries syntax in the tabs:

+

{parsingError}

+ + ) + } + + const sendBatchRequest = () => { + const operations: FetcherParamsWithDocument[] = []; + let headers = {}; + for (const selectedOperation of selectedOperations) { + const [tabId, selectedOperationName] = selectedOperation.split('|'); + const tab = tabsWithOperations[tabId] + if (tab) { + const selectedOperationDefinition = tab.operations.find( + (operation, i) => + operation.name?.value === selectedOperationName || + `${tab.id}|${i}` === selectedOperation + ) + if (selectedOperationDefinition) { + headers = {...headers, ...tab.headers}; + const document = filterDocumentByOperationName(tab.document, selectedOperationDefinition.name?.value); + console.log(`filtered document op: ${selectedOperationDefinition.name?.value}\n`, print(document)); + operations.push({ + document, + operationName: selectedOperationDefinition.name?.value, + query: print(document), + variables: tab.variables + }) + }; + } + } + + let payload: FetcherParams[] | FetcherParams = []; + if (batchingStrategy === BatchStrategy.ARRAY) { + payload = operations.map(({query, operationName, variables}) => ({ operationName, query, variables })); + } else if (batchingStrategy === BatchStrategy.ALIASES) { + const mergedRequests = mergeRequests( + operations.map(({document, operationName, variables}) => ({ + operationName, + document, + variables + })) + ); + + console.log('merged requests:\n', print(mergedRequests.document)); + + payload = { + query: print(mergedRequests.document), + operationName: mergedRequests.operationName, + variables: mergedRequests.variables + } + } + + setBatchResponseLoading(true); + + window.fetch(url, { + method: 'POST', + body: JSON.stringify(payload), + headers: { + 'content-type': 'application/json', + ...headers + } + }).then(response => response.json()) + .then(json => { + setBatchResponseLoading(false); + responseEditor?.setValue(JSON.stringify(json, null, 2)) + }) + }; + + return ( +
+
+
+
Batch Request
+
+
+ +
+
+
+
+

+ A batch GraphQL request requires at least 1 operation. +

+

0 ? 'block' : 'none' + }}> + You have selected {selectedOperations.length === 1 ? `${selectedOperations.length} operation.` : `${selectedOperations.length} operations.`} +

+
+
+
+
Batch strategy
+
+ How to batch operations. +
+
+
+ + + + +
+
+ , + expandOpen: , + parentClose: null, + parentOpen: null, + leaf: null + }} + nodes={nodes} + checked={selectedOperations} + expanded={expandedOperations} + onCheck={checked => { + setSelectedOperations(checked); + setExecuteButtonDisabled(checked.length === 0); + }} + onExpand={setExpandedOperations} + expandOnClick + /> + { batchResponseLoading ? : null } +
+
+ ); +} \ No newline at end of file diff --git a/packages/graphiql-plugin-batch-request/src/filter-document.ts b/packages/graphiql-plugin-batch-request/src/filter-document.ts new file mode 100644 index 00000000000..6b180e36158 --- /dev/null +++ b/packages/graphiql-plugin-batch-request/src/filter-document.ts @@ -0,0 +1,70 @@ +import { + DocumentNode, FragmentDefinitionNode, Kind, OperationDefinitionNode, + SelectionSetNode +} from 'graphql'; + +export const filterDocumentByOperationName = ( + document: DocumentNode, + operationName?: string +): DocumentNode => { + let filteredOperation: OperationDefinitionNode | undefined; + const fragments: Record = {}; + + for (const definition of document.definitions) { + if ( + definition.kind === Kind.OPERATION_DEFINITION && + definition.name?.value === operationName + ) { + filteredOperation = definition; + } else if (definition.kind === Kind.FRAGMENT_DEFINITION) { + fragments[definition.name.value] = definition; + } + } + + const getSelectedFragments = ( + selectionSet: SelectionSetNode | undefined + ): Record => { + + if (!selectionSet) { + return {}; + } + + let selectedFragments: Record = {}; + + for(const selection of selectionSet.selections) { + if(selection.kind === Kind.FRAGMENT_SPREAD) { + const selectedFragment = fragments[selection.name.value]; + selectedFragments = { + ...selectedFragments, + [selection.name.value]: selectedFragment, + ...getSelectedFragments(selectedFragment.selectionSet) + }; + } else { + // technically at this point the only SelectionNode types we are looking for are + // FieldNode (Kind.FIELD) and InlineFragmentNode (Kind.INLINE_FRAGMENT) + // but leting validation handle that. + selectedFragments = { + ...selectedFragments, + ...getSelectedFragments(selection.selectionSet) + }; + } + } + + return selectedFragments; + } + + if (filteredOperation) { + return { + kind: Kind.DOCUMENT, + definitions: [ + ...Object.values(getSelectedFragments(filteredOperation.selectionSet)), + filteredOperation + ] + }; + } + + return { + kind: Kind.DOCUMENT, + definitions: [] + }; +} \ No newline at end of file diff --git a/packages/graphiql-plugin-batch-request/src/graphiql-batch-request.d.ts b/packages/graphiql-plugin-batch-request/src/graphiql-batch-request.d.ts new file mode 100644 index 00000000000..c8b59805aa1 --- /dev/null +++ b/packages/graphiql-plugin-batch-request/src/graphiql-batch-request.d.ts @@ -0,0 +1,26 @@ +declare module 'graphiql-batch-request' { + import { ComponentType } from 'react'; + import { DocumentNode, OperationDefinitionNode } from 'graphql/language'; + import { FetcherParams } from '@graphiql/toolkit'; + + type TabWithOperations = { + id: string, + document: DocumentNode, + operations: OperationDefinitionNode[], + variables: Record, + headers: Record + } + + export type TabsWithOperations = Record; + + export type FetcherParamsWithDocument = FetcherParams & { document: DocumentNode, operationName?: string }; + + export type GraphiQLBatchRequestProps = { + url: string, + useAllOperations?: boolean + } + + const GraphiQLBatchRequest: ComponentType; + + export default GraphiQLBatchRequest; +} diff --git a/packages/graphiql-plugin-batch-request/src/index.css b/packages/graphiql-plugin-batch-request/src/index.css new file mode 100644 index 00000000000..2968db93552 --- /dev/null +++ b/packages/graphiql-plugin-batch-request/src/index.css @@ -0,0 +1,67 @@ +.graphiql-batch-request-header { + display: flex; + justify-content: space-between; + position: relative; +} + +.graphiql-batch-request-header-content { + display: flex; + flex-direction: column; + min-width: 0; +} + +.graphiql-batch-request-title { + font-weight: var(--font-weight-medium); + font-size: var(--font-size-h2); + overflow-x: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.graphiql-batch-request-send { + height: 100%; + position: absolute; + right: 0; + top: 0; +} + +.graphiql-batch-request-strategy-section { + align-items: center; + display: flex; + justify-content: space-between; + padding: var(--px-2); +} + +.graphiql-batch-request-strategy-title { + font-size: var(--font-size-h4); + font-weight: var(--font-weight-medium); +} + +.graphiql-batch-request-strategy-caption { + color: hsla(var(--color-neutral),var(--alpha-secondary)); +} + +.graphiql-batch-request-content { + margin: var(--px-16) 0 0; +} + +.graphiql-batch-request-content>* { + color: hsla(var(--color-neutral),var(--alpha-secondary)); + margin-top: var(--px-20); +} + +.graphiql-batch-request-content .rct-node-leaf .rct-title { + color: hsl(var(--color-tertiary)); +} + +.graphiql-execute-button:disabled { + background-color: hsl(var(--color-neutral)); +} + +.graphiql-execute-button:disabled:hover { + background-color: hsl(var(--color-neutral)); +} + +.react-checkbox-tree { + overflow-y: auto; +} diff --git a/packages/graphiql-plugin-batch-request/src/index.tsx b/packages/graphiql-plugin-batch-request/src/index.tsx new file mode 100644 index 00000000000..500b23283b1 --- /dev/null +++ b/packages/graphiql-plugin-batch-request/src/index.tsx @@ -0,0 +1,25 @@ +import { + GraphiQLPlugin +} from '@graphiql/react'; +import { GraphiQLBatchRequestProps } from 'graphiql-batch-request'; +import { BatchRequestPlugin } from './batch-request-plugin'; +import { useMemo, useRef } from 'react'; + +export function useBatchRequestPlugin(props: GraphiQLBatchRequestProps) { + const propsRef = useRef(props); + propsRef.current = props; + return useMemo( + () => ({ + title: 'Batch Request', + icon: () => ( + + + + + + ), + content: () => , + }), + [], + ); +} \ No newline at end of file diff --git a/packages/graphiql-plugin-batch-request/src/merge-requests.ts b/packages/graphiql-plugin-batch-request/src/merge-requests.ts new file mode 100644 index 00000000000..e365631e3f1 --- /dev/null +++ b/packages/graphiql-plugin-batch-request/src/merge-requests.ts @@ -0,0 +1,300 @@ +// copied from https://github.com/ardatan/graphql-tools/blob/master/packages/batch-execute/src/prefix.ts + +import { + visit, + Kind, + OperationDefinitionNode, + DocumentNode, + FragmentDefinitionNode, + VariableDefinitionNode, + SelectionNode, + FragmentSpreadNode, + VariableNode, + InlineFragmentNode, + FieldNode, +} from 'graphql'; +import { ExecutionRequest, getOperationASTFromRequest } from '@graphql-tools/utils'; + +/** + * Merge multiple queries into a single query in such a way that query results + * can be split and transformed as if they were obtained by running original queries. + * + * Merging algorithm involves several transformations: + * 1. Replace top-level fragment spreads with inline fragments (... on Query {}) + * 2. Add unique aliases to all top-level query fields (including those on inline fragments) + * 3. Prefix all variable definitions and variable usages + * 4. Prefix names (and spreads) of fragments + * + * i.e transform: + * [ + * `query Foo($id: ID!) { foo, bar(id: $id), ...FooQuery } + * fragment FooQuery on Query { baz }`, + * + * `query Bar($id: ID!) { foo: baz, bar(id: $id), ... on Query { baz } }` + * ] + * to: + * query ( + * $graphqlTools1_id: ID! + * $graphqlTools2_id: ID! + * ) { + * graphqlTools1_foo: foo, + * graphqlTools1_bar: bar(id: $graphqlTools1_id) + * ... on Query { + * graphqlTools1__baz: baz + * } + * graphqlTools1__foo: baz + * graphqlTools1__bar: bar(id: $graphqlTools1__id) + * ... on Query { + * graphqlTools1__baz: baz + * } + * } + */ +export function mergeRequests( + requests: Array +): ExecutionRequest { + const mergedVariables: Record = Object.create(null); + const mergedVariableDefinitions: Array = []; + const mergedSelections: Array = []; + const mergedFragmentDefinitions: Array = []; + let mergedExtensions: Record = Object.create(null); + + for (const index in requests) { + const request = requests[index]; + const prefixedRequest = prefixRequest(`_${index}_`, request); + + for (const def of prefixedRequest.document.definitions) { + if (def.kind === Kind.OPERATION_DEFINITION) { + mergedSelections.push(...def.selectionSet.selections); + if (def.variableDefinitions) { + mergedVariableDefinitions.push(...def.variableDefinitions); + } + } + if (def.kind === Kind.FRAGMENT_DEFINITION) { + mergedFragmentDefinitions.push(def); + } + } + Object.assign(mergedVariables, prefixedRequest.variables); + mergedExtensions = { + ...mergedExtensions, + ...request.extensions + }; + } + + const firstRequest = requests[0]; + const operationType = firstRequest.operationType ?? getOperationASTFromRequest(firstRequest).operation; + const mergedOperationDefinition: OperationDefinitionNode = { + kind: Kind.OPERATION_DEFINITION, + operation: operationType, + variableDefinitions: mergedVariableDefinitions, + selectionSet: { + kind: Kind.SELECTION_SET, + selections: mergedSelections, + }, + }; + const operationName = firstRequest.operationName ?? firstRequest.info?.operation?.name?.value; + if (operationName) { + (mergedOperationDefinition as any).name = { + kind: Kind.NAME, + value: operationName, + }; + } + + return { + document: { + kind: Kind.DOCUMENT, + definitions: [mergedOperationDefinition, ...mergedFragmentDefinitions], + }, + variables: mergedVariables, + extensions: mergedExtensions, + context: requests[0].context, + info: requests[0].info, + operationType, + }; +} + +function prefixRequest(prefix: string, request: ExecutionRequest): ExecutionRequest { + const executionVariables = request.variables ?? {}; + + const prefixNode = (node: VariableNode | FragmentDefinitionNode | FragmentSpreadNode) => prefixNodeName(node, prefix); + + let prefixedDocument = aliasTopLevelFields(prefix, request.document); + + const executionVariableNames = Object.keys(executionVariables); + const hasFragmentDefinitions = request.document.definitions.some(def => def.kind === Kind.FRAGMENT_DEFINITION); + const fragmentSpreadImpl: Record = {}; + + if (executionVariableNames.length > 0 || hasFragmentDefinitions) { + prefixedDocument = visit(prefixedDocument, { + [Kind.VARIABLE]: prefixNode, + [Kind.FRAGMENT_DEFINITION]: prefixNode, + [Kind.FRAGMENT_SPREAD]: node => { + node = prefixNodeName(node, prefix); + fragmentSpreadImpl[node.name.value] = true; + return node; + }, + }) as DocumentNode; + } + + const prefixedVariables: Record = {}; + + for (const variableName of executionVariableNames) { + prefixedVariables[prefix + variableName] = executionVariables[variableName]; + } + + if (hasFragmentDefinitions) { + prefixedDocument = { + ...prefixedDocument, + definitions: prefixedDocument.definitions.filter(def => { + return !(def.kind === Kind.FRAGMENT_DEFINITION) || fragmentSpreadImpl[def.name.value]; + }), + }; + } + + return { + document: prefixedDocument, + variables: prefixedVariables + }; +} + +/** + * Adds prefixed aliases to top-level fields of the query. + * + * @see aliasFieldsInSelection for implementation details + */ +function aliasTopLevelFields(prefix: string, document: DocumentNode): DocumentNode { + const transformer = { + [Kind.OPERATION_DEFINITION]: (def: OperationDefinitionNode) => { + const { selections } = def.selectionSet; + return { + ...def, + selectionSet: { + ...def.selectionSet, + selections: aliasFieldsInSelection(prefix, selections, document), + }, + }; + }, + }; + return visit(document, transformer, { + [Kind.DOCUMENT]: [`definitions`], + } as any); +} + +/** + * Add aliases to fields of the selection, including top-level fields of inline fragments. + * Fragment spreads are converted to inline fragments and their top-level fields are also aliased. + * + * Note that this method is shallow. It adds aliases only to the top-level fields and doesn't + * descend to field sub-selections. + * + * For example, transforms: + * { + * foo + * ... on Query { foo } + * ...FragmentWithBarField + * } + * To: + * { + * graphqlTools1_foo: foo + * ... on Query { graphqlTools1_foo: foo } + * ... on Query { graphqlTools1_bar: bar } + * } + */ +function aliasFieldsInSelection( + prefix: string, + selections: ReadonlyArray, + document: DocumentNode +): Array { + return selections.map(selection => { + switch (selection.kind) { + case Kind.INLINE_FRAGMENT: + return aliasFieldsInInlineFragment(prefix, selection, document); + case Kind.FRAGMENT_SPREAD: { + const inlineFragment = inlineFragmentSpread(selection, document); + return aliasFieldsInInlineFragment(prefix, inlineFragment, document); + } + case Kind.FIELD: + default: + return aliasField(selection, prefix); + } + }); +} + +/** + * Add aliases to top-level fields of the inline fragment. + * Returns new inline fragment node. + * + * For Example, transforms: + * ... on Query { foo, ... on Query { bar: foo } } + * To + * ... on Query { graphqlTools1_foo: foo, ... on Query { graphqlTools1_bar: foo } } + */ +function aliasFieldsInInlineFragment( + prefix: string, + fragment: InlineFragmentNode, + document: DocumentNode +): InlineFragmentNode { + return { + ...fragment, + selectionSet: { + ...fragment.selectionSet, + selections: aliasFieldsInSelection(prefix, fragment.selectionSet.selections, document), + }, + }; +} + +/** + * Replaces fragment spread with inline fragment + * + * Example: + * query { ...Spread } + * fragment Spread on Query { bar } + * + * Transforms to: + * query { ... on Query { bar } } + */ +function inlineFragmentSpread(spread: FragmentSpreadNode, document: DocumentNode): InlineFragmentNode { + const fragment = document.definitions.find( + def => def.kind === Kind.FRAGMENT_DEFINITION && def.name.value === spread.name.value + ) as FragmentDefinitionNode; + if (!fragment) { + throw new Error(`Fragment ${spread.name.value} does not exist`); + } + const { typeCondition, selectionSet } = fragment; + return { + kind: Kind.INLINE_FRAGMENT, + typeCondition, + selectionSet, + directives: spread.directives, + }; +} + +function prefixNodeName( + node: T, + prefix: string +): T { + return { + ...node, + name: { + ...node.name, + value: `${prefix}${node.name.value}`, + }, + }; +} + +/** + * Returns a new FieldNode with prefixed alias + * + * Example. Given prefix === "graphqlTools1_" transforms: + * { foo } -> { graphqlTools1_foo: foo } + * { foo: bar } -> { graphqlTools1_foo: bar } + */ +function aliasField(field: FieldNode, aliasPrefix: string): FieldNode { + const aliasNode = field.alias ?? field.name; + return { + ...field, + alias: { + ...aliasNode, + value: `${aliasPrefix}${aliasNode.value}`, + }, + }; +} \ No newline at end of file diff --git a/packages/graphiql-plugin-batch-request/src/vite-env.d.ts b/packages/graphiql-plugin-batch-request/src/vite-env.d.ts new file mode 100644 index 00000000000..11f02fe2a00 --- /dev/null +++ b/packages/graphiql-plugin-batch-request/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/graphiql-plugin-batch-request/test-plugin.tsx b/packages/graphiql-plugin-batch-request/test-plugin.tsx new file mode 100644 index 00000000000..2c0c50f3901 --- /dev/null +++ b/packages/graphiql-plugin-batch-request/test-plugin.tsx @@ -0,0 +1,28 @@ +import { render } from 'react-dom'; +import { createGraphiQLFetcher } from '@graphiql/toolkit'; +import { GraphiQL } from 'graphiql'; +import * as React from 'react'; +import { useBatchRequestPlugin } from './src/index'; + +// const url = 'https://swapi-graphql.netlify.app/.netlify/functions/index'; +// const url = 'https://api.spacex.land/graphql'; +const url = 'https://countries.trevorblades.com/graphql'; + +const fetcher = createGraphiQLFetcher({ url }); + +const App = () => { + const [query, setQuery] = React.useState(''); + const batchRequestPlugin = useBatchRequestPlugin({ url }); + const defaultEditorToolsVisibility = true; + return ( + + ); +} + +render(, document.getElementById('root')); \ No newline at end of file diff --git a/packages/graphiql-plugin-batch-request/test/filter-document-by-operation-name.test.ts b/packages/graphiql-plugin-batch-request/test/filter-document-by-operation-name.test.ts new file mode 100644 index 00000000000..6c59da11fcc --- /dev/null +++ b/packages/graphiql-plugin-batch-request/test/filter-document-by-operation-name.test.ts @@ -0,0 +1,135 @@ +import { filterDocumentByOperationName } from '../src/filter-document'; +import { FragmentDefinitionNode, Kind, OperationDefinitionNode, parse } from 'graphql'; + +describe('filterDocumentByOperationName', () => { + it('should filter document with only 1 operation definition', () => { + const document = parse(` + fragment ItemFragment on Item { id } + query GetItem($id: ID!) { + item(id: $id) { ...ItemFragment } + } + `); + + const filteredDocument = filterDocumentByOperationName(document, 'GetItem'); + expect(filteredDocument.definitions.length).toEqual(2); + + const operationDefinition = filteredDocument.definitions.find(definition => + definition.kind === Kind.OPERATION_DEFINITION + ) as OperationDefinitionNode | undefined; + expect(operationDefinition?.name?.value).toEqual('GetItem'); + + const fragmentDefinition = filteredDocument.definitions.find(definition => + definition.kind === Kind.FRAGMENT_DEFINITION + ) as FragmentDefinitionNode | undefined; + expect(fragmentDefinition?.name?.value).toEqual('ItemFragment'); + }); + + it('should filter document with multiple operation definitions', () => { + const document = parse(` + fragment ItemFragment on Item { id } + fragment UserFragment on User { id } + fragment ReviewsFragment on Review { + id + user { ...UserFragment } + } + query GetItem($id: ID!) { + item(id: $id) { ...ItemFragment } + } + query GetItemsAndReviews { + reviews { ...ReviewsFragment } + items { ...ItemFragment } + } + `); + + const filteredDocument = filterDocumentByOperationName(document, 'GetItemsAndReviews'); + expect(filteredDocument.definitions.length).toEqual(4); + + const operationDefinition = filteredDocument.definitions.find(definition => + definition.kind === Kind.OPERATION_DEFINITION + ) as OperationDefinitionNode | undefined; + expect(operationDefinition?.name?.value).toEqual('GetItemsAndReviews'); + + const fragmentDefinitions = filteredDocument.definitions.filter(definition => + definition.kind === Kind.FRAGMENT_DEFINITION + ) as FragmentDefinitionNode[] | undefined; + expect( + fragmentDefinitions?.map(def => def.name.value) + ).toEqual( + ['ReviewsFragment', 'UserFragment', 'ItemFragment'] + ); + }); + + it('should filter document with multiple operations and 1 anonymous operation', () => { + const document = parse(` + fragment ItemFragment on Item { id } + fragment UserFragment on User { id } + fragment ReviewsFragment on Review { + id + user { ...UserFragment } + } + query GetItem($id: ID!) { + item(id: $id) { ...ItemFragment } + } + query GetItemsAndReviews { + reviews { ...ReviewsFragment } + items { ...ItemFragment } + } + { + reviews(ids: [1,2,3]) { ...ReviewsFragment } + } + `); + + const filteredDocument = filterDocumentByOperationName(document); + expect(filteredDocument.definitions.length).toEqual(3); + + const operationDefinition = filteredDocument.definitions.find(definition => + definition.kind === Kind.OPERATION_DEFINITION + ) as OperationDefinitionNode | undefined; + expect(operationDefinition?.name?.value).toBeUndefined(); + + const fragmentDefinitions = filteredDocument.definitions.filter(definition => + definition.kind === Kind.FRAGMENT_DEFINITION + ) as FragmentDefinitionNode[] | undefined; + expect( + fragmentDefinitions?.map(def => def.name.value) + ).toEqual( + ['ReviewsFragment', 'UserFragment'] + ); + }); + + it('should not filter document when no operation defitinion matches provided operation name', () => { + const document = parse(` + fragment ItemFragment on Item { id } + fragment UserFragment on User { id } + fragment ReviewsFragment on Review { + id + user { ...UserFragment } + } + query GetItem($id: ID!) { + item(id: $id) { ...ItemFragment } + } + query GetItemsAndReviews { + reviews { ...ReviewsFragment } + items { ...ItemFragment } + } + `); + + const filteredDocument = filterDocumentByOperationName(document, 'MyAwesomeQuery'); + expect(filteredDocument.definitions.length).toEqual(0); + }); + + it('should not filter document without repeated fagment definitions', () => { + const document = parse(` + fragment ItemFragment on Item { id } + query GetItem { + item_1: item(id: 1) { ...ItemFragment } + item_2: item(id: 2) { ...ItemFragment } + item_3: item(id: 3) { ...ItemFragment } + item_4: item(id: 4) { ...ItemFragment } + } + `); + + const filteredDocument = filterDocumentByOperationName(document, 'GetItem'); + expect(filteredDocument.definitions.length).toEqual(2); + }); +}); diff --git a/packages/graphiql-plugin-batch-request/tsconfig.json b/packages/graphiql-plugin-batch-request/tsconfig.json new file mode 100644 index 00000000000..872ce46c3eb --- /dev/null +++ b/packages/graphiql-plugin-batch-request/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "declarationDir": "types", + "jsx": "react-jsx" + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/packages/graphiql-plugin-batch-request/tsconfig.node.json b/packages/graphiql-plugin-batch-request/tsconfig.node.json new file mode 100644 index 00000000000..9d31e2aed93 --- /dev/null +++ b/packages/graphiql-plugin-batch-request/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/graphiql-plugin-batch-request/vite.config.ts b/packages/graphiql-plugin-batch-request/vite.config.ts new file mode 100644 index 00000000000..91ec9e3b367 --- /dev/null +++ b/packages/graphiql-plugin-batch-request/vite.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + build: { + lib: { + entry: 'src/index.tsx', + fileName: 'graphiql-plugin-batch-request', + name: 'GraphiQLPluginBatchRequest', + formats: ['cjs', 'es', 'umd'], + }, + rollupOptions: { + external: ['@graphiql/react', 'graphql', 'react', 'react-dom'], + output: { + chunkFileNames: '[name].[format].js', + globals: { + '@graphiql/react': 'GraphiQL.React', + graphql: 'GraphiQL.GraphQL', + react: 'React', + 'react-dom': 'ReactDOM', + }, + }, + }, + commonjsOptions: { + esmExternals: true, + requireReturnsDefault: 'auto', + }, + }, +}); diff --git a/yarn.lock b/yarn.lock index c132d889026..36c23aa2a8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2307,6 +2307,30 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@fortawesome/fontawesome-free@6.2.1": + version "6.2.1" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.2.1.tgz#344baf6ff9eaad7a73cff067d8c56bfc11ae5304" + integrity sha512-viouXhegu/TjkvYQoiRZK3aax69dGXxgEjpvZW81wIJdxm5Fnvp3VVIP4VHKqX4SvFw6qpmkILkD4RJWAdrt7A== + +"@graphiql/react@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@graphiql/react/-/react-0.15.0.tgz#b10fc40c5a85e7b5b845907d57bfd8be92753037" + integrity sha512-kJqkdf6d4Cck05Wt5yCDZXWfs7HZgcpuoWq/v8nOa698qVaNMM3qdG4CpRsZEexku0DSSJzWWuanxd5x+sRcFg== + dependencies: + "@graphiql/toolkit" "^0.8.0" + "@reach/combobox" "^0.17.0" + "@reach/dialog" "^0.17.0" + "@reach/listbox" "^0.17.0" + "@reach/menu-button" "^0.17.0" + "@reach/tooltip" "^0.17.0" + "@reach/visually-hidden" "^0.17.0" + codemirror "^5.65.3" + codemirror-graphql "^2.0.2" + copy-to-clipboard "^3.2.0" + graphql-language-service "^5.1.0" + markdown-it "^12.2.0" + set-value "^4.1.0" + "@graphql-tools/batch-execute@8.5.1": version "8.5.1" resolved "https://registry.yarnpkg.com/@graphql-tools/batch-execute/-/batch-execute-8.5.1.tgz#fa3321d58c64041650be44250b1ebc3aab0ba7a9" @@ -2475,6 +2499,13 @@ dependencies: tslib "^2.4.0" +"@graphql-tools/utils@9.1.4": + version "9.1.4" + resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-9.1.4.tgz#2c9e0aefc9655dd73247667befe3c850ec014f3f" + integrity sha512-hgIeLt95h9nQgQuzbbdhuZmh+8WV7RZ/6GbTj6t3IU4Zd2zs9yYJ2jgW/krO587GMOY8zCwrjNOMzD40u3l7Vg== + dependencies: + tslib "^2.4.0" + "@graphql-tools/wrap@8.5.1": version "8.5.1" resolved "https://registry.yarnpkg.com/@graphql-tools/wrap/-/wrap-8.5.1.tgz#d4bd1f89850bb1ce0209f35f66d002080b439495" @@ -5772,6 +5803,11 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" +classnames@^2.2.5: + version "2.3.2" + resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" + integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== + clean-css@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78" @@ -12200,7 +12236,7 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.7.0: +lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -12881,7 +12917,7 @@ nanoid@3.3.3: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.3.tgz#fd8e8b7aa761fe807dba2d1b98fb7241bb724a25" integrity sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w== -nanoid@^3.3.3: +nanoid@^3.0.0, nanoid@^3.3.3: version "3.3.4" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== @@ -14708,7 +14744,7 @@ prop-types@15.7.2, prop-types@^15.6.1, prop-types@^15.6.2: object-assign "^4.1.1" react-is "^16.8.1" -prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.5.8, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -14945,6 +14981,16 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.2.7, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-checkbox-tree@1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/react-checkbox-tree/-/react-checkbox-tree-1.8.0.tgz#10dc3e86df25d92559ab6857c7ada4fe285abc03" + integrity sha512-ufC4aorihOvjLpvY1beab2hjVLGZbDTFRzw62foG0+th+KX7e/sdmWu/nD1ZS/U5Yr0rWGwedGH5GOtR0IkUXw== + dependencies: + classnames "^2.2.5" + lodash "^4.17.10" + nanoid "^3.0.0" + prop-types "^15.5.8" + react-clientside-effect@^1.2.6: version "1.2.6" resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.6.tgz#29f9b14e944a376b03fb650eed2a754dd128ea3a"