diff --git a/.changeset/@graphql-tools_apollo-engine-loader-4640-dependencies.md b/.changeset/@graphql-tools_apollo-engine-loader-4640-dependencies.md new file mode 100644 index 00000000000..f097f93f3da --- /dev/null +++ b/.changeset/@graphql-tools_apollo-engine-loader-4640-dependencies.md @@ -0,0 +1,7 @@ +--- +"@graphql-tools/apollo-engine-loader": patch +--- + +dependencies updates: + +- Updated dependency [`@whatwg-node/fetch@^0.2.9` ↗︎](https://www.npmjs.com/package/@whatwg-node/fetch/v/^0.2.9) (was `^0.2.4`, in `dependencies`) diff --git a/.changeset/@graphql-tools_batch-delegate-4640-dependencies.md b/.changeset/@graphql-tools_batch-delegate-4640-dependencies.md new file mode 100644 index 00000000000..a31c6cce6d6 --- /dev/null +++ b/.changeset/@graphql-tools_batch-delegate-4640-dependencies.md @@ -0,0 +1,7 @@ +--- +"@graphql-tools/batch-delegate": patch +--- + +dependencies updates: + +- Updated dependency [`@graphql-tools/delegate@9.0.3` ↗︎](https://www.npmjs.com/package/@graphql-tools/delegate/v/9.0.3) (was `9.0.2`, in `dependencies`) diff --git a/.changeset/@graphql-tools_github-loader-4640-dependencies.md b/.changeset/@graphql-tools_github-loader-4640-dependencies.md new file mode 100644 index 00000000000..0df9352d40f --- /dev/null +++ b/.changeset/@graphql-tools_github-loader-4640-dependencies.md @@ -0,0 +1,7 @@ +--- +"@graphql-tools/github-loader": patch +--- + +dependencies updates: + +- Updated dependency [`@whatwg-node/fetch@^0.2.9` ↗︎](https://www.npmjs.com/package/@whatwg-node/fetch/v/^0.2.9) (was `^0.2.4`, in `dependencies`) diff --git a/.changeset/@graphql-tools_links-4640-dependencies.md b/.changeset/@graphql-tools_links-4640-dependencies.md new file mode 100644 index 00000000000..0f754487eb3 --- /dev/null +++ b/.changeset/@graphql-tools_links-4640-dependencies.md @@ -0,0 +1,7 @@ +--- +"@graphql-tools/links": patch +--- + +dependencies updates: + +- Updated dependency [`@graphql-tools/delegate@9.0.3` ↗︎](https://www.npmjs.com/package/@graphql-tools/delegate/v/9.0.3) (was `9.0.2`, in `dependencies`) diff --git a/.changeset/@graphql-tools_prisma-loader-4640-dependencies.md b/.changeset/@graphql-tools_prisma-loader-4640-dependencies.md new file mode 100644 index 00000000000..a8d658bc064 --- /dev/null +++ b/.changeset/@graphql-tools_prisma-loader-4640-dependencies.md @@ -0,0 +1,7 @@ +--- +"@graphql-tools/prisma-loader": patch +--- + +dependencies updates: + +- Updated dependency [`@graphql-tools/url-loader@7.13.7` ↗︎](https://www.npmjs.com/package/@graphql-tools/url-loader/v/7.13.7) (was `7.13.6`, in `dependencies`) diff --git a/.changeset/@graphql-tools_stitch-4640-dependencies.md b/.changeset/@graphql-tools_stitch-4640-dependencies.md new file mode 100644 index 00000000000..4b27d38984c --- /dev/null +++ b/.changeset/@graphql-tools_stitch-4640-dependencies.md @@ -0,0 +1,9 @@ +--- +"@graphql-tools/stitch": patch +--- + +dependencies updates: + +- Updated dependency [`@graphql-tools/batch-delegate@8.3.5` ↗︎](https://www.npmjs.com/package/@graphql-tools/batch-delegate/v/8.3.5) (was `8.3.4`, in `dependencies`) +- Updated dependency [`@graphql-tools/delegate@9.0.3` ↗︎](https://www.npmjs.com/package/@graphql-tools/delegate/v/9.0.3) (was `9.0.2`, in `dependencies`) +- Updated dependency [`@graphql-tools/wrap@9.0.3` ↗︎](https://www.npmjs.com/package/@graphql-tools/wrap/v/9.0.3) (was `9.0.2`, in `dependencies`) diff --git a/.changeset/@graphql-tools_stitching-directives-4640-dependencies.md b/.changeset/@graphql-tools_stitching-directives-4640-dependencies.md new file mode 100644 index 00000000000..bb77c868c86 --- /dev/null +++ b/.changeset/@graphql-tools_stitching-directives-4640-dependencies.md @@ -0,0 +1,7 @@ +--- +"@graphql-tools/stitching-directives": patch +--- + +dependencies updates: + +- Updated dependency [`@graphql-tools/delegate@9.0.3` ↗︎](https://www.npmjs.com/package/@graphql-tools/delegate/v/9.0.3) (was `9.0.2`, in `dependencies`) diff --git a/.changeset/@graphql-tools_url-loader-4640-dependencies.md b/.changeset/@graphql-tools_url-loader-4640-dependencies.md new file mode 100644 index 00000000000..b7677f2e5f3 --- /dev/null +++ b/.changeset/@graphql-tools_url-loader-4640-dependencies.md @@ -0,0 +1,9 @@ +--- +"@graphql-tools/url-loader": patch +--- + +dependencies updates: + +- Updated dependency [`@graphql-tools/delegate@9.0.3` ↗︎](https://www.npmjs.com/package/@graphql-tools/delegate/v/9.0.3) (was `9.0.2`, in `dependencies`) +- Updated dependency [`@graphql-tools/wrap@9.0.3` ↗︎](https://www.npmjs.com/package/@graphql-tools/wrap/v/9.0.3) (was `9.0.2`, in `dependencies`) +- Updated dependency [`@whatwg-node/fetch@^0.2.9` ↗︎](https://www.npmjs.com/package/@whatwg-node/fetch/v/^0.2.9) (was `^0.2.4`, in `dependencies`) diff --git a/.changeset/@graphql-tools_wrap-4640-dependencies.md b/.changeset/@graphql-tools_wrap-4640-dependencies.md new file mode 100644 index 00000000000..5f7a2e777ac --- /dev/null +++ b/.changeset/@graphql-tools_wrap-4640-dependencies.md @@ -0,0 +1,7 @@ +--- +"@graphql-tools/wrap": patch +--- + +dependencies updates: + +- Updated dependency [`@graphql-tools/delegate@9.0.3` ↗︎](https://www.npmjs.com/package/@graphql-tools/delegate/v/9.0.3) (was `9.0.2`, in `dependencies`) diff --git a/.changeset/early-baboons-kiss.md b/.changeset/early-baboons-kiss.md new file mode 100644 index 00000000000..21b4e595495 --- /dev/null +++ b/.changeset/early-baboons-kiss.md @@ -0,0 +1,8 @@ +--- +'@graphql-tools/links': patch +'@graphql-tools/apollo-engine-loader': patch +'@graphql-tools/github-loader': patch +'@graphql-tools/url-loader': patch +--- + +Bump fetch package diff --git a/.changeset/mighty-eels-tap.md b/.changeset/mighty-eels-tap.md new file mode 100644 index 00000000000..74ebc26e8ec --- /dev/null +++ b/.changeset/mighty-eels-tap.md @@ -0,0 +1,5 @@ +--- +'@graphql-tools/url-loader': patch +--- + +Even if 'multipart' is set to true but there is no files in the variables, still use regular JSON request diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3805ec97d97..405d904ddd3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -111,6 +111,7 @@ jobs: name: Unit Test on Node ${{matrix.node-version}} (${{matrix.os}}) and GraphQL v${{matrix.graphql_version}} runs-on: ${{matrix.os}} strategy: + fail-fast: false matrix: os: [ubuntu-latest] # remove windows to speed up the tests node-version: [14, 16, 18] @@ -214,6 +215,6 @@ jobs: - name: Build Packages run: yarn build - name: Test - run: yarn test --ci browser + run: yarn jest --no-watchman --ci browser env: TEST_BROWSER: true diff --git a/package.json b/package.json index 3f014c2d8a6..c213306a486 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "lint": "eslint --ext .ts .", "prettier": "prettier --ignore-path .prettierignore --write --list-different .", "prettier:check": "prettier --ignore-path .prettierignore --check .", - "test": "jest --no-watchman --detectOpenHandles --logHeapUsage", + "test": "jest --no-watchman --detectOpenHandles --detectLeaks", "prerelease": "yarn build", "release": "changeset publish" }, diff --git a/packages/links/package.json b/packages/links/package.json index bc61db73900..49de43241b5 100644 --- a/packages/links/package.json +++ b/packages/links/package.json @@ -61,7 +61,6 @@ "devDependencies": { "@apollo/client": "3.6.9", "@types/apollo-upload-client": "17.0.1", - "@graphql-yoga/node": "2.13.6", "graphql-upload": "16.0.1" }, "dependencies": { diff --git a/packages/links/tests/upload.test.ts b/packages/links/tests/upload.test.ts index cda647312fe..35208d97b70 100644 --- a/packages/links/tests/upload.test.ts +++ b/packages/links/tests/upload.test.ts @@ -3,12 +3,11 @@ import { AddressInfo } from 'net'; import { Readable } from 'stream'; import express, { Express } from 'express'; -import { createServer } from '@graphql-yoga/node'; import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import FormData from 'form-data'; import fetch from 'node-fetch'; -import { buildSchema } from 'graphql'; +import { buildSchema, execute, GraphQLSchema, parse } from 'graphql'; import { makeExecutableSchema } from '@graphql-tools/schema'; import { stitchSchemas } from '@graphql-tools/stitch'; @@ -57,6 +56,21 @@ function testGraphqlMultipartRequest(query: string, port: number) { }); } +function getBasicGraphQLMiddleware(schema: GraphQLSchema) { + return (req: any, res: any) => { + Promise.resolve().then(async () => { + const { query, variables, operationName } = req.body; + const result = await execute({ + schema, + document: parse(query), + variableValues: variables, + operationName, + }); + res.json(result); + }); + }; +} + describe('graphql upload', () => { let remoteServer: Server; let remotePort: number; @@ -89,9 +103,8 @@ describe('graphql upload', () => { const remoteApp = express().use( graphqlUploadExpress(), - createServer({ - schema: remoteSchema, - }) + // Yoga causes leak, so we are removing that for now + getBasicGraphQLMiddleware(remoteSchema) ); remoteServer = await startServer(remoteApp); @@ -123,12 +136,7 @@ describe('graphql upload', () => { }, }); - const gatewayApp = express().use( - graphqlUploadExpress(), - createServer({ - schema: gatewaySchema, - }) - ); + const gatewayApp = express().use(graphqlUploadExpress(), getBasicGraphQLMiddleware(gatewaySchema)); gatewayServer = await startServer(gatewayApp); gatewayPort = (gatewayServer.address() as AddressInfo).port; diff --git a/packages/loaders/apollo-engine/package.json b/packages/loaders/apollo-engine/package.json index 92f00e80350..bc153ec708a 100644 --- a/packages/loaders/apollo-engine/package.json +++ b/packages/loaders/apollo-engine/package.json @@ -53,7 +53,7 @@ "dependencies": { "@ardatan/sync-fetch": "0.0.1", "@graphql-tools/utils": "8.10.0", - "@whatwg-node/fetch": "^0.2.4", + "@whatwg-node/fetch": "^0.2.9", "tslib": "^2.4.0" }, "publishConfig": { diff --git a/packages/loaders/github/package.json b/packages/loaders/github/package.json index ffd2024860b..f4d1b0aa8f8 100644 --- a/packages/loaders/github/package.json +++ b/packages/loaders/github/package.json @@ -54,7 +54,7 @@ "@ardatan/sync-fetch": "0.0.1", "@graphql-tools/utils": "8.10.0", "@graphql-tools/graphql-tag-pluck": "7.3.3", - "@whatwg-node/fetch": "^0.2.4", + "@whatwg-node/fetch": "^0.2.9", "tslib": "^2.4.0" }, "publishConfig": { diff --git a/packages/loaders/url/package.json b/packages/loaders/url/package.json index bec54ad30e2..e550eef5e8d 100644 --- a/packages/loaders/url/package.json +++ b/packages/loaders/url/package.json @@ -52,7 +52,6 @@ }, "devDependencies": { "@envelop/live-query": "4.0.1", - "@graphql-yoga/node": "2.13.6", "@types/express": "4.17.13", "@types/extract-files": "8.1.1", "@types/valid-url": "1.0.3", @@ -71,7 +70,7 @@ "@ardatan/sync-fetch": "0.0.1", "@n1ru4l/graphql-live-query": "^0.10.0", "@types/ws": "^8.0.0", - "@whatwg-node/fetch": "^0.2.4", + "@whatwg-node/fetch": "^0.2.9", "dset": "^3.1.2", "extract-files": "^11.0.0", "graphql-ws": "^5.4.1", diff --git a/packages/loaders/url/src/addCancelToResponseStream.ts b/packages/loaders/url/src/addCancelToResponseStream.ts deleted file mode 100644 index 8731fa2e6cd..00000000000 --- a/packages/loaders/url/src/addCancelToResponseStream.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { withCancel } from '@graphql-tools/utils'; -export function addCancelToResponseStream(resultStream: AsyncIterable, controller: AbortController) { - return withCancel(resultStream, () => { - if (!controller.signal.aborted) { - controller.abort(); - } - }); -} diff --git a/packages/loaders/url/src/event-stream/addCancelToResponseStream.ts b/packages/loaders/url/src/event-stream/addCancelToResponseStream.ts new file mode 100644 index 00000000000..794615bb607 --- /dev/null +++ b/packages/loaders/url/src/event-stream/addCancelToResponseStream.ts @@ -0,0 +1,25 @@ +import { withCancel } from '@graphql-tools/utils'; + +export function cancelNeeded() { + if (globalThis.process?.versions?.node) { + const [nodeMajorStr, nodeMinorStr] = process.versions.node.split('.'); + + const nodeMajor = parseInt(nodeMajorStr); + const nodeMinor = parseInt(nodeMinorStr); + + if (nodeMajor > 16 || (nodeMajor === 16 && nodeMinor >= 5)) { + return false; + } + return true; + } + + return false; +} + +export function addCancelToResponseStream(resultStream: AsyncIterable, controller: AbortController) { + return withCancel(resultStream, () => { + if (!controller.signal.aborted) { + controller.abort(); + } + }); +} diff --git a/packages/loaders/url/src/event-stream/handleEventStreamResponse.ts b/packages/loaders/url/src/event-stream/handleEventStreamResponse.ts index 939db47b6cb..e81715bf846 100644 --- a/packages/loaders/url/src/event-stream/handleEventStreamResponse.ts +++ b/packages/loaders/url/src/event-stream/handleEventStreamResponse.ts @@ -2,12 +2,16 @@ import { ExecutionResult } from 'graphql'; import { inspect, isAsyncIterable } from '@graphql-tools/utils'; import { handleAsyncIterable } from './handleAsyncIterable.js'; import { handleReadableStream } from './handleReadableStream.js'; +import { addCancelToResponseStream } from './addCancelToResponseStream.js'; export function isReadableStream(value: any): value is ReadableStream { return value && typeof value.getReader === 'function'; } -export async function handleEventStreamResponse(response: Response): Promise> { +export async function handleEventStreamResponse( + response: Response, + controller?: AbortController +): Promise> { // node-fetch returns body as a promise so we need to resolve it const body = response.body; if (body) { @@ -15,7 +19,12 @@ export async function handleEventStreamResponse(response: Response): Promise(body)) { - return handleAsyncIterable(body); + const resultStream = handleAsyncIterable(body); + if (controller) { + return addCancelToResponseStream(resultStream, controller); + } else { + return resultStream; + } } } throw new Error('Response body is expected to be a readable stream but got; ' + inspect(body)); diff --git a/packages/loaders/url/src/handleMultipartMixedResponse.ts b/packages/loaders/url/src/handleMultipartMixedResponse.ts index 6be55d1f222..15c7176f5bd 100644 --- a/packages/loaders/url/src/handleMultipartMixedResponse.ts +++ b/packages/loaders/url/src/handleMultipartMixedResponse.ts @@ -4,6 +4,7 @@ import { meros as merosIncomingMessage } from 'meros/node'; import { meros as merosReadableStream } from 'meros/browser'; import { mapAsyncIterator } from '@graphql-tools/utils'; import { dset } from 'dset/merge'; +import { addCancelToResponseStream } from './event-stream/addCancelToResponseStream.js'; interface ExecutionPatchResult { errors?: ReadonlyArray; @@ -28,7 +29,7 @@ function isIncomingMessage(body: any): body is IncomingMessage { return body != null && typeof body === 'object' && 'pipe' in body; } -export async function handleMultipartMixedResponse(response: Response) { +export async function handleMultipartMixedResponse(response: Response, controller?: AbortController) { const body = await (response.body as unknown as Promise | ReadableStream); const contentType = response.headers.get('content-type') || ''; let asyncIterator: AsyncIterator; @@ -45,7 +46,8 @@ export async function handleMultipartMixedResponse(response: Response) { } const executionResult: ExecutionResult = {}; - return mapAsyncIterator(asyncIterator, (part: Part) => { + + const resultStream = mapAsyncIterator(asyncIterator, (part: Part) => { if (part.json) { const chunk = part.body; if (chunk.path) { @@ -67,4 +69,10 @@ export async function handleMultipartMixedResponse(response: Response) { return executionResult; } }); + + if (controller) { + return addCancelToResponseStream(resultStream, controller); + } + + return resultStream; } diff --git a/packages/loaders/url/src/index.ts b/packages/loaders/url/src/index.ts index 7653862b888..3e12e44f16a 100644 --- a/packages/loaders/url/src/index.ts +++ b/packages/loaders/url/src/index.ts @@ -26,7 +26,7 @@ import { AsyncFetchFn, defaultAsyncFetch } from './defaultAsyncFetch.js'; import { defaultSyncFetch, SyncFetchFn } from './defaultSyncFetch.js'; import { handleMultipartMixedResponse } from './handleMultipartMixedResponse.js'; import { handleEventStreamResponse } from './event-stream/handleEventStreamResponse.js'; -import { addCancelToResponseStream } from './addCancelToResponseStream.js'; +import { cancelNeeded } from './event-stream/addCancelToResponseStream.js'; import { AbortController, FormData, File } from '@whatwg-node/fetch'; import { isBlob, isGraphQLUpload, isPromiseLike, LEGACY_WS } from './utils.js'; @@ -186,6 +186,14 @@ export class UrlLoader implements Loader { v?.then || typeof v?.arrayBuffer === 'function') as any ); + if (files.size === 0) { + return JSON.stringify({ + query, + variables, + operationName, + extensions, + }); + } const map: Record = {}; const uploads: any[] = []; let currIndex = 0; @@ -213,9 +221,7 @@ export class UrlLoader implements Loader { return upload.then((resolvedUpload: any) => handleUpload(resolvedUpload, i)); // If Blob } else if (isBlob(upload)) { - return upload.arrayBuffer().then((arrayBuffer: ArrayBuffer) => { - form.append(indexStr, new File([arrayBuffer], filename, { type: upload.type }), filename); - }); + form.append(indexStr, upload, filename); } else if (isGraphQLUpload(upload)) { const stream = upload.createReadStream(); const chunks: number[] = []; @@ -299,7 +305,7 @@ export class UrlLoader implements Loader { ws: 'http', }); const executor = (request: ExecutionRequest) => { - const controller = new AbortController(); + const controller = cancelNeeded() ? new AbortController() : undefined; let method = defaultMethod; const operationAst = getOperationASTFromRequest(request); @@ -309,7 +315,7 @@ export class UrlLoader implements Loader { method = 'GET'; } - let accept = 'application/json, multipart/mixed'; + let accept = 'application/json'; if (operationType === 'subscription' || isLiveQueryOperationDefinitionNode(operationAst)) { method = 'GET'; accept = 'text/event-stream'; @@ -335,8 +341,8 @@ export class UrlLoader implements Loader { let timeoutId: any; if (options?.timeout) { timeoutId = setTimeout(() => { - if (!controller.signal.aborted) { - controller.abort(); + if (!controller?.signal.aborted) { + controller?.abort(); } }, options.timeout); } @@ -354,19 +360,22 @@ export class UrlLoader implements Loader { method: 'GET', ...(credentials != null ? { credentials } : {}), headers, - signal: controller.signal, + signal: controller?.signal, }); case 'POST': if (options?.multipart) { return new ValueOrPromise(() => this.createFormDataFromVariables(requestBody)) .then( - form => + body => fetch(endpoint, { method: 'POST', ...(credentials != null ? { credentials } : {}), - body: form as any, - headers, - signal: controller.signal, + body, + headers: { + ...headers, + ...(typeof body === 'string' ? { 'content-type': 'application/json' } : {}), + }, + signal: controller?.signal, }) as any ) .resolve(); @@ -379,7 +388,7 @@ export class UrlLoader implements Loader { 'content-type': 'application/json', ...headers, }, - signal: controller.signal, + signal: controller?.signal, }); } } @@ -397,13 +406,9 @@ export class UrlLoader implements Loader { const contentType = fetchResult.headers.get('content-type'); if (contentType?.includes('text/event-stream')) { - return handleEventStreamResponse(fetchResult).then(resultStream => - addCancelToResponseStream(resultStream, controller) - ); + return handleEventStreamResponse(fetchResult, controller); } else if (contentType?.includes('multipart/mixed')) { - return handleMultipartMixedResponse(fetchResult).then(resultStream => - addCancelToResponseStream(resultStream, controller) - ); + return handleMultipartMixedResponse(fetchResult, controller); } return fetchResult.text(); diff --git a/packages/loaders/url/tests/__snapshots__/url-loader.spec.ts.snap b/packages/loaders/url/tests/__snapshots__/sync.spec.ts.snap similarity index 99% rename from packages/loaders/url/tests/__snapshots__/url-loader.spec.ts.snap rename to packages/loaders/url/tests/__snapshots__/sync.spec.ts.snap index d70c1c36dac..e91fc9783f5 100644 --- a/packages/loaders/url/tests/__snapshots__/url-loader.spec.ts.snap +++ b/packages/loaders/url/tests/__snapshots__/sync.spec.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Schema URL Loader handle sync should handle introspection 1`] = ` +exports[`sync should handle introspection 1`] = ` "schema { query: Root } @@ -1006,7 +1006,7 @@ type VehiclesEdge { }" `; -exports[`Schema URL Loader handle sync should handle queries 1`] = ` +exports[`sync should handle queries 1`] = ` Object { "data": Object { "allFilms": Object { diff --git a/packages/loaders/url/tests/graphql-upload.spec.ts b/packages/loaders/url/tests/graphql-upload.spec.ts new file mode 100644 index 00000000000..1a6e3df0ef9 --- /dev/null +++ b/packages/loaders/url/tests/graphql-upload.spec.ts @@ -0,0 +1,96 @@ +import { File } from '@whatwg-node/fetch'; +import { readFileSync } from 'fs'; +import { execute, GraphQLSchema, parse } from 'graphql'; +import { join } from 'path'; +import { assertNonMaybe, testSchema } from './test-utils'; +import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; +import http from 'http'; +import { UrlLoader } from '../src'; +import express from 'express'; + +function getBasicGraphQLMiddleware(schema: GraphQLSchema) { + return (req: any, res: any) => { + Promise.resolve() + .then(async () => { + const { query, variables, operationName } = req.body; + const result = await execute({ + schema, + document: parse(query), + variableValues: variables, + operationName, + }); + res.json(result); + }) + .catch(err => { + res.status(500).json({ + errors: [ + { + message: err.message, + }, + ], + }); + }); + }; +} + +describe('GraphQL Upload compatibility', () => { + const loader = new UrlLoader(); + let httpServer: http.Server; + + afterEach(async () => { + if (httpServer !== undefined) { + await new Promise((resolve, reject) => + httpServer.close(err => { + if (err) { + reject(err); + } else { + resolve(); + } + }) + ); + } + }); + + it('should handle file uploads in graphql-upload way', async () => { + const expressApp = express().use(express.json(), graphqlUploadExpress(), getBasicGraphQLMiddleware(testSchema)); + + httpServer = await new Promise(resolve => { + const server = expressApp.listen(9871, () => { + resolve(server); + }); + }); + + const [{ schema }] = await loader.load(`http://0.0.0.0:9871/graphql`, { + multipart: true, + }); + + const fileName = 'testfile.txt'; + + const absoluteFilePath = join(__dirname, fileName); + + const content = readFileSync(absoluteFilePath, 'utf8'); + assertNonMaybe(schema); + const result = await execute({ + schema, + document: parse(/* GraphQL */ ` + mutation UploadFile($file: Upload!, $nullVar: TestInput, $nonObjectVar: String) { + uploadFile(file: $file, dummyVar: $nullVar, secondDummyVar: $nonObjectVar) { + filename + content + } + } + `), + variableValues: { + file: new File([content], fileName, { type: 'text/plain' }), + nullVar: null, + nonObjectVar: 'somefilename.txt', + }, + }); + + expect(result.errors).toBeFalsy(); + assertNonMaybe(result.data); + const uploadFileData: any = result.data?.['uploadFile']; + expect(uploadFileData?.filename).toBe(fileName); + expect(uploadFileData?.content).toBe(content); + }); +}); diff --git a/packages/loaders/url/tests/helix-yoga-compat.spec.ts b/packages/loaders/url/tests/helix-yoga-compat.spec.ts new file mode 100644 index 00000000000..16b4a7192d8 --- /dev/null +++ b/packages/loaders/url/tests/helix-yoga-compat.spec.ts @@ -0,0 +1,285 @@ +import { execute, ExecutionResult, parse } from 'graphql'; +import { assertAsyncIterable, sleep } from './test-utils'; +import http from 'http'; +import { SubscriptionProtocol, UrlLoader } from '../src'; +import { GraphQLLiveDirectiveSDL } from '@envelop/live-query'; +import { InMemoryLiveQueryStore } from '@n1ru4l/in-memory-live-query-store'; +import { LiveExecutionResult } from '@n1ru4l/graphql-live-query'; +import { isAsyncIterable } from '@graphql-tools/utils'; +import { makeExecutableSchema } from '@graphql-tools/schema'; + +describe('helix/yoga compat', () => { + const loader = new UrlLoader(); + let httpServer: http.Server; + + afterEach(async () => { + if (httpServer !== undefined) { + await new Promise(resolve => httpServer.close(() => resolve())); + } + }); + + it('should handle multipart response result', async () => { + const chunkDatas = [ + { data: { foo: {} }, hasNext: true }, + { data: { a: 1 }, path: ['foo'], hasNext: true }, + { data: { a: 1, b: 2 }, path: ['foo'], hasNext: false }, + ]; + const expectedDatas: ExecutionResult[] = [ + { + data: { + foo: {}, + }, + }, + { + data: { + foo: { + a: 1, + }, + }, + }, + { + data: { + foo: { + a: 1, + b: 2, + }, + }, + }, + ]; + const serverPort = 1335; + const serverHost = 'http://localhost:' + serverPort; + httpServer = http.createServer((_, res) => { + res.writeHead(200, { + // prettier-ignore + "Connection": "keep-alive", + 'Content-Type': 'multipart/mixed; boundary="-"', + 'Transfer-Encoding': 'chunked', + }); + + res.write(`---`); + + chunkDatas.forEach(chunkData => + sleep(300).then(() => { + const chunk = Buffer.from(JSON.stringify(chunkData), 'utf8'); + const data = ['', 'Content-Type: application/json; charset=utf-8', '', chunk, '', `---`]; + res.write(data.join('\r\n')); + }) + ); + + sleep(1000).then(() => { + res.write('\r\n-----\r\n'); + res.end(); + }); + }); + await new Promise(resolve => httpServer.listen(serverPort, resolve)); + + const executor = loader.getExecutorAsync(serverHost); + const result = await executor({ + document: parse(/* GraphQL */ ` + query { + foo { + ... on Foo @defer { + a + b + } + } + } + `), + }); + + assertAsyncIterable(result); + for await (const data of result) { + expect(data).toEqual(expectedDatas.shift()!); + } + expect(expectedDatas.length).toBe(0); + }); + + it('should handle SSE subscription result', async () => { + const expectedDatas: ExecutionResult[] = [{ data: { foo: 1 } }, { data: { foo: 2 } }, { data: { foo: 3 } }]; + const serverPort = 1336; + const serverHost = 'http://localhost:' + serverPort; + + httpServer = http.createServer((_, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + // prettier-ignore + "Connection": "keep-alive", + 'Cache-Control': 'no-cache', + }); + + expectedDatas.forEach(result => sleep(300).then(() => res.write(`data: ${JSON.stringify(result)}\n\n`))); + + sleep(1000).then(() => res.end()); + }); + + await new Promise(resolve => httpServer.listen(serverPort, () => resolve())); + + const executor = loader.getExecutorAsync(`${serverHost}/graphql`, { + subscriptionsProtocol: SubscriptionProtocol.SSE, + }); + const result = await executor({ + document: parse(/* GraphQL */ ` + subscription { + foo + } + `), + }); + assertAsyncIterable(result); + + for await (const singleResult of result) { + expect(singleResult).toStrictEqual(expectedDatas.shift()!); + } + expect(expectedDatas.length).toBe(0); + }); + it('terminates SSE subscriptions when calling return on the AsyncIterable', async () => { + const sentDatas: ExecutionResult[] = [ + { data: { foo: 0 } }, + { data: { foo: 1 } }, + { data: { foo: 2 } }, + { data: { foo: 3 } }, + ]; + const serverPort = 1336 + Math.floor(Math.random() * 5); + const serverHost = 'http://localhost:' + serverPort; + + httpServer = http.createServer((_, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + // prettier-ignore + "Connection": "keep-alive", + 'Cache-Control': 'no-cache', + }); + + let foo = 0; + const ping = setInterval(() => { + // Ping + res.write( + `data: ${JSON.stringify({ + data: { + foo, + }, + })}\n\n` + ); + foo++; + }, 300); + res.once('close', () => { + clearInterval(ping); + }); + }); + + await new Promise(resolve => httpServer.listen(serverPort, () => resolve())); + + const executor = loader.getExecutorAsync(`${serverHost}/graphql`, { + subscriptionsProtocol: SubscriptionProtocol.SSE, + }); + const result = await executor({ + document: parse(/* GraphQL */ ` + subscription { + foo + } + `), + }); + assertAsyncIterable(result); + + for await (const singleResult of result) { + const expectedData = sentDatas.shift(); + if (expectedData == null) { + break; + } + expect(singleResult).toStrictEqual(expectedData); + } + + expect(sentDatas.length).toBe(0); + }); + describe('live queries', () => { + const urlLoader = new UrlLoader(); + let active = false; + let cnt = 0; + beforeAll(async () => { + const liveQueryStore = new InMemoryLiveQueryStore(); + function pump() { + if (active) { + cnt++; + liveQueryStore.invalidate('Query.cnt').then(() => { + setTimeout(pump, 100); + }); + } + } + active = true; + pump(); + const liveExecute = liveQueryStore.makeExecute(execute); + const schema = makeExecutableSchema({ + typeDefs: [ + /* GraphQL */ ` + type Query { + cnt: Int! + } + `, + GraphQLLiveDirectiveSDL, + ], + resolvers: { + Query: { + cnt: () => cnt, + }, + }, + }); + httpServer = http.createServer((req, res) => { + let closed = false; + res.on('close', () => { + closed = true; + }); + const queryParams = new URLSearchParams(req.url!.split('?')[1]); + const query = queryParams.get('query')!; + const variablesStr = queryParams.get('variables'); + let variables = {}; + if (variablesStr) { + variables = JSON.parse(variablesStr); + } + Promise.resolve(liveExecute({ schema, document: parse(query), variableValues: variables })).then( + async result => { + if (isAsyncIterable(result)) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + }); + for await (const data of result) { + if (closed) { + return; + } + res.write(`data: ${JSON.stringify(data)}\n\n`); + } + res.end(); + return; + } + res.writeHead(200, { + 'Content-Type': 'application/json', + }); + res.end(JSON.stringify(result)); + } + ); + }); + await new Promise(resolve => httpServer.listen(9877, resolve)); + }); + afterAll(() => { + active = false; + }); + it('should handle live queries', async () => { + const executor = urlLoader.getExecutorAsync(`http://localhost:9877/graphql`, { + subscriptionsProtocol: SubscriptionProtocol.SSE, + }); + const result = await executor({ + document: parse(/* GraphQL */ ` + query Count @live { + cnt + } + `), + }); + assertAsyncIterable(result); + for await (const singleResult of result) { + expect(singleResult.data.cnt).toBe(cnt); + expect((singleResult as LiveExecutionResult).isLive); + if (cnt >= 3) { + break; + } + } + }); + }); +}); diff --git a/packages/loaders/url/tests/sync.spec.ts b/packages/loaders/url/tests/sync.spec.ts new file mode 100644 index 00000000000..cb745e43a0c --- /dev/null +++ b/packages/loaders/url/tests/sync.spec.ts @@ -0,0 +1,26 @@ +import { printSchemaWithDirectives } from '@graphql-tools/utils'; +import { GraphQLSchema, graphqlSync } from 'graphql'; +import { UrlLoader } from '../src'; + +describe('sync', () => { + const loader = new UrlLoader(); + it('should handle introspection', () => { + const [{ schema }] = loader.loadSync(`https://swapi-graphql.netlify.app/.netlify/functions/index`, {}); + expect(schema).toBeInstanceOf(GraphQLSchema); + expect(printSchemaWithDirectives(schema!).trim()).toMatchSnapshot(); + }); + it('should handle queries', () => { + const [{ schema }] = loader.loadSync(`https://swapi-graphql.netlify.app/.netlify/functions/index`, {}); + const result = graphqlSync({ + schema: schema!, + source: /* GraphQL */ ` + { + allFilms { + totalCount + } + } + `, + }); + expect(result).toMatchSnapshot(); + }); +}); diff --git a/packages/loaders/url/tests/test-utils.ts b/packages/loaders/url/tests/test-utils.ts new file mode 100644 index 00000000000..76501dd55a1 --- /dev/null +++ b/packages/loaders/url/tests/test-utils.ts @@ -0,0 +1,114 @@ +import { isAsyncIterable, inspect } from '@graphql-tools/utils'; +import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; +import { makeExecutableSchema } from '@graphql-tools/schema'; + +export function assertAsyncIterable(input: unknown): asserts input is AsyncIterable { + if (!isAsyncIterable(input)) { + throw new Error(`Expected AsyncIterable. but received: ${inspect(input)}`); + } +} + +export function assertNonMaybe(input: T): asserts input is Exclude { + if (input == null) { + throw new Error('Value should be neither null nor undefined.'); + } +} + +export function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export const testTypeDefs = /* GraphQL */ ` +schema { query: CustomQuery +mutation: Mutation +subscription: Subscription } +"""The \`Upload\` scalar type represents a file upload.""" +scalar Upload +"""Test type comment""" +type CustomQuery { + """Test field comment""" + a(testVariable: String): String + + complexField(complexArg: ComplexInput): ComplexType +} + +input ComplexInput { + id: ID +} + +input ComplexChildInput { + id: ID +} + +type ComplexType { + id: ID + complexChildren(complexChildArg: ComplexChildInput): [ComplexChild] +} + +type ComplexChild { + id: ID +} + +type Mutation { + uploadFile(file: Upload, dummyVar: TestInput, secondDummyVar: String): File +} +type File { + filename: String + mimetype: String + encoding: String + content: String +} +type Subscription { + testMessage: TestMessage +} +type TestMessage { + number: Int +} +input TestInput { + testField: String +} +`.trim(); + +export const testResolvers = { + CustomQuery: { + a: (_: never, { testVariable }: { testVariable: string }) => testVariable || 'a', + complexField: (_: never, { complexArg }: { complexArg: { id: string } }) => { + return complexArg; + }, + }, + ComplexType: { + complexChildren: (_: never, { complexChildArg }: { complexChildArg: { id: string } }) => { + return [{ id: complexChildArg.id }]; + }, + }, + Upload: GraphQLUpload, + File: { + content: async (file: any) => { + const stream: NodeJS.ReadableStream = file.createReadStream(); + let data = ''; + for await (const chunk of stream) { + data += chunk; + } + return data; + }, + }, + Mutation: { + uploadFile: async (_: never, { file }: any) => file, + }, + Subscription: { + testMessage: { + subscribe: async function* () { + for (let i = 0; i < 3; i++) { + yield { number: i }; + } + }, + resolve: (payload: any) => payload, + }, + }, +}; + +export const testSchema = makeExecutableSchema({ typeDefs: testTypeDefs, resolvers: testResolvers }); + +export const testHost = `http://localhost:3000`; +export const testPath = '/graphql'; +export const testUrl = `${testHost}${testPath}`; diff --git a/packages/loaders/url/tests/url-loader-browser.spec.ts b/packages/loaders/url/tests/url-loader-browser.spec.ts index f40ab1e3398..bce6c1fa953 100644 --- a/packages/loaders/url/tests/url-loader-browser.spec.ts +++ b/packages/loaders/url/tests/url-loader-browser.spec.ts @@ -149,7 +149,7 @@ describe('[url-loader] webpack bundle compat', () => { async (httpAddress, document) => { const module = window['GraphQLToolsUrlLoader'] as typeof UrlLoaderModule; const loader = new module.UrlLoader(); - const executor = await loader.getExecutorAsync(httpAddress + '/graphql'); + const executor = loader.getExecutorAsync(httpAddress + '/graphql'); const result = await executor({ document, }); @@ -164,7 +164,7 @@ describe('[url-loader] webpack bundle compat', () => { it('handles executing a operation using multipart responses', async () => { page = await browser.newPage(); await page.goto(httpAddress); - graphqlHandler = async (_req, res) => { + graphqlHandler = (_req, res) => { res.writeHead(200, { // prettier-ignore "Connection": "keep-alive", @@ -175,11 +175,12 @@ describe('[url-loader] webpack bundle compat', () => { let chunk = Buffer.from(JSON.stringify({ data: {} }), 'utf8'); let data = ['', 'Content-Type: application/json; charset=utf-8', '', chunk, '', `---`]; res.write(data.join('\r\n')); - await new Promise(resolve => setTimeout(resolve, 300)); - chunk = Buffer.from(JSON.stringify({ data: true, path: ['foo'] }), 'utf8'); - data = ['', 'Content-Type: application/json; charset=utf-8', '', chunk, '', `---`]; - res.write(data.join('\r\n')); - res.end(); + setTimeout(() => { + chunk = Buffer.from(JSON.stringify({ data: true, path: ['foo'] }), 'utf8'); + data = ['', 'Content-Type: application/json; charset=utf-8', '', chunk, '', `---`]; + res.write(data.join('\r\n')); + res.end(); + }, 300); }; const document = parse(/* GraphQL */ ` @@ -194,7 +195,7 @@ describe('[url-loader] webpack bundle compat', () => { async (httpAddress, document) => { const module = window['GraphQLToolsUrlLoader'] as typeof UrlLoaderModule; const loader = new module.UrlLoader(); - const executor = await loader.getExecutorAsync(httpAddress + '/graphql'); + const executor = loader.getExecutorAsync(httpAddress + '/graphql'); const result = await executor({ document, }); @@ -216,7 +217,7 @@ describe('[url-loader] webpack bundle compat', () => { const expectedDatas = [{ data: { foo: true } }, { data: { foo: false } }]; - graphqlHandler = async (_req, res) => { + graphqlHandler = (_req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', // prettier-ignore @@ -224,13 +225,15 @@ describe('[url-loader] webpack bundle compat', () => { 'Cache-Control': 'no-cache', }); - for (const data of expectedDatas) { - await new Promise(resolve => setTimeout(resolve, 300)); - res.write(`data: ${JSON.stringify(data)}\n\n`); - await new Promise(resolve => setTimeout(resolve, 300)); - } + Promise.resolve().then(async () => { + for (const data of expectedDatas) { + await new Promise(resolve => setTimeout(resolve, 300)); + res.write(`data: ${JSON.stringify(data)}\n\n`); + await new Promise(resolve => setTimeout(resolve, 300)); + } - res.end(); + res.end(); + }); }; const document = parse(/* GraphQL */ ` @@ -243,7 +246,7 @@ describe('[url-loader] webpack bundle compat', () => { async (httpAddress, document) => { const module = window['GraphQLToolsUrlLoader'] as typeof UrlLoaderModule; const loader = new module.UrlLoader(); - const executor = await loader.getExecutorAsync(httpAddress + '/graphql', { + const executor = loader.getExecutorAsync(httpAddress + '/graphql', { subscriptionsProtocol: module.SubscriptionProtocol.SSE, }); const result = await executor({ @@ -268,7 +271,7 @@ describe('[url-loader] webpack bundle compat', () => { let responseClosed$: Promise; - graphqlHandler = async (_req, res) => { + graphqlHandler = (_req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', // prettier-ignore @@ -283,13 +286,15 @@ describe('[url-loader] webpack bundle compat', () => { res.write(':\n\n'); }, 100); - for (const data of sentDatas) { - await new Promise(resolve => setTimeout(resolve, 300)); - res.write(`data: ${JSON.stringify(data)}\n\n`); - await new Promise(resolve => setTimeout(resolve, 300)); - } + Promise.resolve().then(async () => { + for (const data of sentDatas) { + await new Promise(resolve => setTimeout(resolve, 300)); + res.write(`data: ${JSON.stringify(data)}\n\n`); + await new Promise(resolve => setTimeout(resolve, 300)); + } - clearInterval(ping); + clearInterval(ping); + }); }; const document = parse(/* GraphQL */ ` @@ -305,7 +310,7 @@ describe('[url-loader] webpack bundle compat', () => { async (httpAddress, document) => { const module = window['GraphQLToolsUrlLoader'] as typeof UrlLoaderModule; const loader = new module.UrlLoader(); - const executor = await loader.getExecutorAsync(httpAddress + '/graphql', { + const executor = loader.getExecutorAsync(httpAddress + '/graphql', { subscriptionsProtocol: module.SubscriptionProtocol.SSE, }); const result = (await executor({ diff --git a/packages/loaders/url/tests/url-loader.spec.ts b/packages/loaders/url/tests/url-loader.spec.ts index c50aead9e72..ca6f0e0a5f0 100644 --- a/packages/loaders/url/tests/url-loader.spec.ts +++ b/packages/loaders/url/tests/url-loader.spec.ts @@ -1,7 +1,6 @@ import '../../../testing/to-be-similar-gql-doc'; -import { makeExecutableSchema } from '@graphql-tools/schema'; import { SubscriptionProtocol, UrlLoader } from '../src/index.js'; -import { isAsyncIterable, printSchemaWithDirectives } from '@graphql-tools/utils'; +import { printSchemaWithDirectives } from '@graphql-tools/utils'; import { execute, subscribe, @@ -11,132 +10,20 @@ import { introspectionFromSchema, getIntrospectionQuery, getOperationAST, - GraphQLSchema, - graphqlSync, } from 'graphql'; -import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs'; -import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; -import { readFileSync } from 'fs'; -import { join } from 'path'; import { useServer } from 'graphql-ws/lib/use/ws'; import { createHandler } from 'graphql-sse'; import { Server as WSServer } from 'ws'; import http from 'http'; import { SubscriptionServer } from 'subscriptions-transport-ws'; import { AsyncFetchFn, defaultAsyncFetch } from '../src/defaultAsyncFetch.js'; -import { Response, File, Headers } from '@whatwg-node/fetch'; -import express from 'express'; -import { inspect } from 'util'; -import { createServer } from '@graphql-yoga/node'; -import { GraphQLLiveDirectiveSDL, useLiveQuery } from '@envelop/live-query'; -import { InMemoryLiveQueryStore } from '@n1ru4l/in-memory-live-query-store'; -import { LiveExecutionResult } from '@n1ru4l/graphql-live-query'; +import { Response, Headers } from '@whatwg-node/fetch'; import { loadSchema } from '@graphql-tools/load'; +import { testUrl, testSchema, testTypeDefs, assertNonMaybe } from './test-utils'; describe('Schema URL Loader', () => { const loader = new UrlLoader(); - const testTypeDefs = /* GraphQL */ ` -schema { query: CustomQuery -mutation: Mutation -subscription: Subscription } -"""The \`Upload\` scalar type represents a file upload.""" -scalar Upload -"""Test type comment""" -type CustomQuery { - """Test field comment""" - a(testVariable: String): String - - complexField(complexArg: ComplexInput): ComplexType -} - -input ComplexInput { - id: ID -} - -input ComplexChildInput { - id: ID -} - -type ComplexType { - id: ID - complexChildren(complexChildArg: ComplexChildInput): [ComplexChild] -} - -type ComplexChild { - id: ID -} - -type Mutation { - uploadFile(file: Upload, dummyVar: TestInput, secondDummyVar: String): File -} -type File { - filename: String - mimetype: String - encoding: String - content: String -} -type Subscription { - testMessage: TestMessage -} -type TestMessage { - number: Int -} -input TestInput { - testField: String -} -`.trim(); - - const testResolvers = { - CustomQuery: { - a: (_: never, { testVariable }: { testVariable: string }) => testVariable || 'a', - complexField: (_: never, { complexArg }: { complexArg: { id: string } }) => { - return complexArg; - }, - }, - ComplexType: { - complexChildren: (_: never, { complexChildArg }: { complexChildArg: { id: string } }) => { - return [{ id: complexChildArg.id }]; - }, - }, - Upload: GraphQLUpload, - File: { - content: async (file: any) => { - const stream: NodeJS.ReadableStream = file.createReadStream(); - let data = ''; - for await (const chunk of stream) { - data += chunk; - } - return data; - }, - }, - Mutation: { - uploadFile: async (_: never, { file }: any) => file, - }, - Subscription: { - testMessage: { - subscribe: async function* () { - for (let i = 0; i < 3; i++) { - yield { number: i }; - } - }, - resolve: (payload: any) => payload, - }, - }, - }; - - const testSchema = makeExecutableSchema({ typeDefs: testTypeDefs, resolvers: testResolvers }); - - const testHost = `http://localhost:3000`; - const testPath = '/graphql'; - const testUrl = `${testHost}${testPath}`; - - function assertNonMaybe(input: T): asserts input is Exclude { - if (input == null) { - throw new Error('Value should be neither null nor undefined.'); - } - } - let httpServer: http.Server; afterEach(async () => { @@ -145,830 +32,532 @@ input TestInput { } }); - describe('handle', () => { - it('Should throw an error when introspection is not valid', async () => { - const brokenData = { data: {} }; + it('Should throw an error when introspection is not valid', async () => { + const brokenData = { data: {} }; - expect.assertions(1); - - try { - await loader.load(testUrl, { - customFetch: async () => { - return new Response(JSON.stringify(brokenData)); - }, - }); - } catch (e: any) { - expect(e.message).toBe('Could not obtain introspection result, received: ' + JSON.stringify(brokenData)); - } - }); + expect.assertions(1); - it('Should return a valid schema when request is valid', async () => { - const [source] = await loader.load(testUrl, { + try { + await loader.load(testUrl, { customFetch: async () => { - return new Response( - JSON.stringify({ - data: introspectionFromSchema(testSchema), - }) - ); + return new Response(JSON.stringify(brokenData)); }, }); - assertNonMaybe(source.schema); - expect(printSchemaWithDirectives(source.schema)).toBeSimilarGqlDoc(testTypeDefs); - }); + } catch (e: any) { + expect(e.message).toBe('Could not obtain introspection result, received: ' + JSON.stringify(brokenData)); + } + }); - it('Should pass default headers', async () => { - let headers: HeadersInit = {}; - const customFetch: AsyncFetchFn = async (_, opts) => { - headers = opts?.headers || {}; + it('Should return a valid schema when request is valid', async () => { + const [source] = await loader.load(testUrl, { + customFetch: async () => { return new Response( JSON.stringify({ data: introspectionFromSchema(testSchema), }) ); - }; - - const [source] = await loader.load(testUrl, { - customFetch, - }); - - expect(source).toBeDefined(); - assertNonMaybe(source.schema); - expect(printSchemaWithDirectives(source.schema)).toBeSimilarGqlDoc(testTypeDefs); + }, + }); + assertNonMaybe(source.schema); + expect(printSchemaWithDirectives(source.schema)).toBeSimilarGqlDoc(testTypeDefs); + }); - expect(Array.isArray(headers['accept']) ? headers['accept'].join(',') : headers['accept']).toContain( - `application/json` + it('Should pass default headers', async () => { + let headers: HeadersInit = {}; + const customFetch: AsyncFetchFn = async (_, opts) => { + headers = opts?.headers || {}; + return new Response( + JSON.stringify({ + data: introspectionFromSchema(testSchema), + }) ); - expect(headers['content-type']).toContain(`application/json`); - }); + }; - it('Should pass extra headers when they are specified as object', async () => { - let headers: HeadersInit = {}; - const customFetch: AsyncFetchFn = async (_, opts) => { - headers = opts?.headers || {}; - return new Response( - JSON.stringify({ - data: introspectionFromSchema(testSchema), - }) - ); - }; + const [source] = await loader.load(testUrl, { + customFetch, + }); - const [source] = await loader.load(testUrl, { - headers: { auth: '1' }, - customFetch, - }); + expect(source).toBeDefined(); + assertNonMaybe(source.schema); + expect(printSchemaWithDirectives(source.schema)).toBeSimilarGqlDoc(testTypeDefs); - expect(source).toBeDefined(); - assertNonMaybe(source.schema); - expect(printSchemaWithDirectives(source.schema)).toBeSimilarGqlDoc(testTypeDefs); + expect(Array.isArray(headers['accept']) ? headers['accept'].join(',') : headers['accept']).toContain( + `application/json` + ); + expect(headers['content-type']).toContain(`application/json`); + }); - expect(Array.isArray(headers['accept']) ? headers['accept'].join(',') : headers['accept']).toContain( - `application/json` + it('Should pass extra headers when they are specified as object', async () => { + let headers: HeadersInit = {}; + const customFetch: AsyncFetchFn = async (_, opts) => { + headers = opts?.headers || {}; + return new Response( + JSON.stringify({ + data: introspectionFromSchema(testSchema), + }) ); - expect(headers['content-type']).toContain(`application/json`); - expect(headers['auth']).toContain(`1`); - }); - - it('Should utilize extra introspection options', async () => { - const introspectionOptions = { - descriptions: false, - }; - const customFetch: AsyncFetchFn = async (_, opts) => { - const receivedBody = JSON.parse(opts?.body?.toString() || '{}'); - const receivedAST = parse(receivedBody.query, { - noLocation: true, - }); - const expectedAST = parse(getIntrospectionQuery(introspectionOptions), { - noLocation: true, - }); - expect(receivedAST).toMatchObject(expectedAST); - return new Response( - JSON.stringify({ - data: introspectionFromSchema(testSchema, introspectionOptions), - }) - ); - }; - - const [source] = await loader.load(testUrl, { - ...introspectionOptions, - customFetch, - }); + }; - expect(source).toBeDefined(); - assertNonMaybe(source.schema); - expect(source.schema.getQueryType()!.description).toBeUndefined(); + const [source] = await loader.load(testUrl, { + headers: { auth: '1' }, + customFetch, }); - it('should handle useGETForQueries correctly', async () => { - const customFetch: AsyncFetchFn = async (url, opts) => { - expect(opts?.method).toBe('GET'); - const { searchParams } = new URL(url.toString()); - const receivedQuery = searchParams.get('query')!; - const receivedAST = parse(receivedQuery, { - noLocation: true, - }); - const receivedOperationName = searchParams.get('operationName'); - const receivedVariables = JSON.parse(searchParams.get('variables') || '{}'); - const operationAST = getOperationAST(receivedAST, receivedOperationName); - expect(operationAST?.operation).toBe('query'); - const responseBody = JSON.stringify( - await execute({ - schema: testSchema, - document: receivedAST, - operationName: receivedOperationName, - variableValues: receivedVariables, - }) - ); - return new Response(responseBody); - }; + expect(source).toBeDefined(); + assertNonMaybe(source.schema); + expect(printSchemaWithDirectives(source.schema)).toBeSimilarGqlDoc(testTypeDefs); - const [source] = await loader.load(testUrl, { - descriptions: false, - useGETForQueries: true, - customFetch, - }); + expect(Array.isArray(headers['accept']) ? headers['accept'].join(',') : headers['accept']).toContain( + `application/json` + ); + expect(headers['content-type']).toContain(`application/json`); + expect(headers['auth']).toContain(`1`); + }); - const testVariableValue = 'A'; + it('Should utilize extra introspection options', async () => { + const introspectionOptions = { + descriptions: false, + }; + const customFetch: AsyncFetchFn = async (_, opts) => { + const receivedBody = JSON.parse(opts?.body?.toString() || '{}'); + const receivedAST = parse(receivedBody.query, { + noLocation: true, + }); + const expectedAST = parse(getIntrospectionQuery(introspectionOptions), { + noLocation: true, + }); + expect(receivedAST).toMatchObject(expectedAST); + return new Response( + JSON.stringify({ + data: introspectionFromSchema(testSchema, introspectionOptions), + }) + ); + }; - assertNonMaybe(source.schema); - const result = await execute({ - schema: source.schema, - document: parse(/* GraphQL */ ` - query TestQuery($testVariable: String) { - a(testVariable: $testVariable) - } - `), - variableValues: { - testVariable: testVariableValue, - }, - }); + const [source] = await loader.load(testUrl, { + ...introspectionOptions, + customFetch, + }); - expect(result?.errors).toBeFalsy(); + expect(source).toBeDefined(); + assertNonMaybe(source.schema); + expect(source.schema.getQueryType()!.description).toBeUndefined(); + }); - expect(result?.data?.['a']).toBe(testVariableValue); + it('should handle useGETForQueries correctly', async () => { + const customFetch: AsyncFetchFn = async (url, opts) => { + expect(opts?.method).toBe('GET'); + const { searchParams } = new URL(url.toString()); + const receivedQuery = searchParams.get('query')!; + const receivedAST = parse(receivedQuery, { + noLocation: true, + }); + const receivedOperationName = searchParams.get('operationName'); + const receivedVariables = JSON.parse(searchParams.get('variables') || '{}'); + const operationAST = getOperationAST(receivedAST, receivedOperationName); + expect(operationAST?.operation).toBe('query'); + const responseBody = JSON.stringify( + await execute({ + schema: testSchema, + document: receivedAST, + operationName: receivedOperationName, + variableValues: receivedVariables, + }) + ); + return new Response(responseBody); + }; - // 2 requests done; one for introspection and second for the actual query - expect.assertions(6); + const [source] = await loader.load(testUrl, { + descriptions: false, + useGETForQueries: true, + customFetch, }); - it('should respect dynamic values given in extensions', async () => { - const customFetch: AsyncFetchFn = async (info, init) => { - expect(info.toString()).toBe('DYNAMIC_ENDPOINT'); - expect(new Headers(init?.headers).get('TEST_HEADER')).toBe('TEST_HEADER_VALUE'); - return new Response( - JSON.stringify({ - data: introspectionFromSchema(testSchema), - }) - ); - }; + const testVariableValue = 'A'; - const executor = loader.getExecutorAsync('SOME_ENDPOINT', { - customFetch, - }); - await executor({ - document: parse(getIntrospectionQuery()), - extensions: { - endpoint: 'DYNAMIC_ENDPOINT', - headers: { - TEST_HEADER: 'TEST_HEADER_VALUE', - }, - }, - }); - expect.assertions(2); + assertNonMaybe(source.schema); + const result = await execute({ + schema: source.schema, + document: parse(/* GraphQL */ ` + query TestQuery($testVariable: String) { + a(testVariable: $testVariable) + } + `), + variableValues: { + testVariable: testVariableValue, + }, }); - it('Should preserve "ws" and "http" in the middle of a pointer', async () => { - const address = { - host: 'http://foo.ws:8080', - path: '/graphql', - }; - const url = address.host + address.path; - const customFetch: AsyncFetchFn = async url => { - expect(url.toString()).toBe(address.host + address.path); - return new Response( - JSON.stringify({ - data: introspectionFromSchema(testSchema), - }) - ); - }; + expect(result?.errors).toBeFalsy(); - await loader.load(url, { - customFetch, - }); - expect.assertions(1); - }); - - it('Should replace ws:// with http:// in buildAsyncExecutor', async () => { - const address = { - host: 'ws://foo:8080', - path: '/graphql', - }; - const url = address.host + address.path; - const customFetch: AsyncFetchFn = async url => { - expect(url.toString()).toBe('http://foo:8080/graphql'); - return new Response( - JSON.stringify({ - data: introspectionFromSchema(testSchema), - }) - ); - }; + expect(result?.data?.['a']).toBe(testVariableValue); - await loader.load(url, { - customFetch, - }); - expect.assertions(1); - }); + // 2 requests done; one for introspection and second for the actual query + expect.assertions(6); + }); - it('Should replace wss:// with https:// in buildAsyncExecutor', async () => { - const address = { - host: 'wss://foo:8080', - path: '/graphql', - }; - const url = address.host + address.path; - const customFetch: AsyncFetchFn = async url => { - expect(url.toString()).toBe('https://foo:8080/graphql'); - return new Response( - JSON.stringify({ - data: introspectionFromSchema(testSchema), - }) - ); - }; + it('should respect dynamic values given in extensions', async () => { + const customFetch: AsyncFetchFn = async (info, init) => { + expect(info.toString()).toBe('DYNAMIC_ENDPOINT'); + expect(new Headers(init?.headers).get('TEST_HEADER')).toBe('TEST_HEADER_VALUE'); + return new Response( + JSON.stringify({ + data: introspectionFromSchema(testSchema), + }) + ); + }; - await loader.load(url, { - customFetch, - }); - expect.assertions(1); + const executor = loader.getExecutorAsync('SOME_ENDPOINT', { + customFetch, }); - it('should handle .graphql files', async () => { - const testHost = 'http://localhost:3000'; - const testPath = '/schema.graphql'; - const [result] = await loader.load(testHost + testPath, { - customFetch: async () => { - return new Response(testTypeDefs); + await executor({ + document: parse(getIntrospectionQuery()), + extensions: { + endpoint: 'DYNAMIC_ENDPOINT', + headers: { + TEST_HEADER: 'TEST_HEADER_VALUE', }, - }); - - assertNonMaybe(result.document); - expect(print(result.document)).toBeSimilarGqlDoc(testTypeDefs); + }, }); + expect.assertions(2); + }); - it('should handle .graphqls files', async () => { - const testHost = 'http://localhost:3000'; - const testPath = '/schema.graphqls'; - const [result] = await loader.load(testHost + testPath, { - customFetch: async () => { - return new Response(testTypeDefs); - }, - }); + it('Should preserve "ws" and "http" in the middle of a pointer', async () => { + const address = { + host: 'http://foo.ws:8080', + path: '/graphql', + }; + const url = address.host + address.path; + const customFetch: AsyncFetchFn = async url => { + expect(url.toString()).toBe(address.host + address.path); + return new Response( + JSON.stringify({ + data: introspectionFromSchema(testSchema), + }) + ); + }; - assertNonMaybe(result.document); - expect(print(result.document)).toBeSimilarGqlDoc(testTypeDefs); + await loader.load(url, { + customFetch, }); + expect.assertions(1); + }); - it("should handle results with handleAsSDL option even if it doesn't end with .graphql", async () => { - const testHost = 'http://localhost:3000'; - const testPath = '/sdl'; - - const [result] = await loader.load(testHost + testPath, { - handleAsSDL: true, - customFetch: async () => { - return new Response(testTypeDefs); - }, - }); + it('Should replace ws:// with http:// in buildAsyncExecutor', async () => { + const address = { + host: 'ws://foo:8080', + path: '/graphql', + }; + const url = address.host + address.path; + const customFetch: AsyncFetchFn = async url => { + expect(url.toString()).toBe('http://foo:8080/graphql'); + return new Response( + JSON.stringify({ + data: introspectionFromSchema(testSchema), + }) + ); + }; - assertNonMaybe(result.document); - expect(print(result.document)).toBeSimilarGqlDoc(testTypeDefs); + await loader.load(url, { + customFetch, }); - it('should handle subscriptions - new graphql-ws', async () => { - const testUrl = 'http://localhost:8081/graphql'; - const [{ schema }] = await loader.load(testUrl, { - customFetch: async () => - new Response( - JSON.stringify({ - data: introspectionFromSchema(testSchema), - }), - { - headers: { - 'content-type': 'application/json', - }, - } - ), - subscriptionsProtocol: SubscriptionProtocol.WS, - }); - - httpServer = http.createServer(function weServeSocketsOnly(_, res) { - res.writeHead(404); - res.end(); - }); - - const wsServer = new WSServer({ - server: httpServer, - path: '/graphql', - }); + expect.assertions(1); + }); - const subscriptionServer = useServer( - { - schema: testSchema, // from the previous step - execute, - subscribe, - }, - wsServer + it('Should replace wss:// with https:// in buildAsyncExecutor', async () => { + const address = { + host: 'wss://foo:8080', + path: '/graphql', + }; + const url = address.host + address.path; + const customFetch: AsyncFetchFn = async url => { + expect(url.toString()).toBe('https://foo:8080/graphql'); + return new Response( + JSON.stringify({ + data: introspectionFromSchema(testSchema), + }) ); + }; - httpServer.listen(8081); - assertNonMaybe(schema); - const asyncIterator = (await subscribe({ - schema, - document: parse(/* GraphQL */ ` - subscription TestMessage { - testMessage { - number - } - } - `), - contextValue: {}, - })) as AsyncIterableIterator; - - expect(asyncIterator['errors']).toBeFalsy(); - expect(asyncIterator['errors']?.length).toBeFalsy(); - - async function getNextResult() { - const result = await asyncIterator.next(); - expect(result?.done).toBeFalsy(); - return result?.value?.data?.testMessage?.number; - } - - expect(await getNextResult()).toBe(0); - expect(await getNextResult()).toBe(1); - expect(await getNextResult()).toBe(2); - - await asyncIterator.return!(); - await subscriptionServer.dispose(); + await loader.load(url, { + customFetch, + }); + expect.assertions(1); + }); + it('should handle .graphql files', async () => { + const testHost = 'http://localhost:3000'; + const testPath = '/schema.graphql'; + const [result] = await loader.load(testHost + testPath, { + customFetch: async () => { + return new Response(testTypeDefs); + }, }); - it('should handle subscriptions - legacy subscriptions-transport-ws', async () => { - const testUrl = 'http://localhost:8081/graphql'; - const [{ schema }] = await loader.load(testUrl, { - customFetch: async () => - new Response( - JSON.stringify({ - data: introspectionFromSchema(testSchema), - }), - { - headers: { - 'content-type': 'application/json', - }, - } - ), - subscriptionsProtocol: SubscriptionProtocol.LEGACY_WS, - }); - httpServer = http.createServer(function weServeSocketsOnly(_, res) { - res.writeHead(404); - res.end(); - }); + assertNonMaybe(result.document); + expect(print(result.document)).toBeSimilarGqlDoc(testTypeDefs); + }); - httpServer.listen(8081); + it('should handle .graphqls files', async () => { + const testHost = 'http://localhost:3000'; + const testPath = '/schema.graphqls'; + const [result] = await loader.load(testHost + testPath, { + customFetch: async () => { + return new Response(testTypeDefs); + }, + }); - const subscriptionServer = SubscriptionServer.create( - { - schema: testSchema, - execute, - subscribe, - }, - { - server: httpServer, - path: '/graphql', - } - ); - assertNonMaybe(schema); - const asyncIterator = (await subscribe({ - schema, - document: parse(/* GraphQL */ ` - subscription TestMessage { - testMessage { - number - } - } - `), - contextValue: {}, - })) as AsyncIterableIterator; + assertNonMaybe(result.document); + expect(print(result.document)).toBeSimilarGqlDoc(testTypeDefs); + }); - expect(asyncIterator['errors']).toBeFalsy(); - expect(asyncIterator['errors']?.length).toBeFalsy(); + it("should handle results with handleAsSDL option even if it doesn't end with .graphql", async () => { + const testHost = 'http://localhost:3000'; + const testPath = '/sdl'; - async function getNextResult() { - const result = await asyncIterator.next(); - expect(result?.done).toBeFalsy(); - return result?.value?.data?.testMessage?.number; - } + const [result] = await loader.load(testHost + testPath, { + handleAsSDL: true, + customFetch: async () => { + return new Response(testTypeDefs); + }, + }); - expect(await getNextResult()).toBe(0); - expect(await getNextResult()).toBe(1); - expect(await getNextResult()).toBe(2); + assertNonMaybe(result.document); + expect(print(result.document)).toBeSimilarGqlDoc(testTypeDefs); + }); + it('should handle subscriptions - new graphql-ws', async () => { + const testUrl = 'http://localhost:8081/graphql'; + const [{ schema }] = await loader.load(testUrl, { + customFetch: async () => + new Response( + JSON.stringify({ + data: introspectionFromSchema(testSchema), + }), + { + headers: { + 'content-type': 'application/json', + }, + } + ), + subscriptionsProtocol: SubscriptionProtocol.WS, + }); - await asyncIterator.return!(); - subscriptionServer.close(); + httpServer = http.createServer(function weServeSocketsOnly(_, res) { + res.writeHead(404); + res.end(); }); - it('should handle subscriptions - graphql-sse', async () => { - const testUrl = 'http://localhost:8081/graphql'; - const customFetch: AsyncFetchFn = async (url, options) => { - if (String(options?.body).includes('IntrospectionQuery')) { - return new Response( - JSON.stringify({ - data: introspectionFromSchema(testSchema), - }), - { - headers: { - 'content-type': 'application/json', - }, - } - ); - } - return defaultAsyncFetch(url, options); - }; - const [{ schema }] = await loader.load(testUrl, { - customFetch, - subscriptionsProtocol: SubscriptionProtocol.GRAPHQL_SSE, - }); + const wsServer = new WSServer({ + server: httpServer, + path: '/graphql', + }); - httpServer = http.createServer( - createHandler({ - schema: testSchema, - }) - ); - httpServer.listen(8081); - - assertNonMaybe(schema); - const asyncIterator = (await subscribe({ - schema, - document: parse(/* GraphQL */ ` - subscription TestMessage { - testMessage { - number - } + const subscriptionServer = useServer( + { + schema: testSchema, // from the previous step + execute, + subscribe, + }, + wsServer + ); + + await new Promise(resolve => httpServer.listen(8081, resolve)); + assertNonMaybe(schema); + const asyncIterator = (await subscribe({ + schema, + document: parse(/* GraphQL */ ` + subscription TestMessage { + testMessage { + number } - `), - contextValue: {}, - })) as AsyncIterableIterator; + } + `), + contextValue: {}, + })) as AsyncIterableIterator; - expect(asyncIterator['errors']).toBeFalsy(); - expect(asyncIterator['errors']?.length).toBeFalsy(); + expect(asyncIterator['errors']).toBeFalsy(); + expect(asyncIterator['errors']?.length).toBeFalsy(); - async function getNextResult() { - const result = await asyncIterator.next(); - expect(result?.done).toBeFalsy(); - return result?.value?.data?.testMessage?.number; - } + async function getNextResult() { + const result = await asyncIterator.next(); + expect(result?.done).toBeFalsy(); + return result?.value?.data?.testMessage?.number; + } - expect(await getNextResult()).toBe(0); - expect(await getNextResult()).toBe(1); - expect(await getNextResult()).toBe(2); + expect(await getNextResult()).toBe(0); + expect(await getNextResult()).toBe(1); + expect(await getNextResult()).toBe(2); - asyncIterator.return!(); + await asyncIterator.return!(); + await subscriptionServer.dispose(); + }); + it('should handle subscriptions - legacy subscriptions-transport-ws', async () => { + const testUrl = 'http://localhost:8081/graphql'; + const [{ schema }] = await loader.load(testUrl, { + customFetch: async () => + new Response( + JSON.stringify({ + data: introspectionFromSchema(testSchema), + }), + { + headers: { + 'content-type': 'application/json', + }, + } + ), + subscriptionsProtocol: SubscriptionProtocol.LEGACY_WS, }); - it('should handle file uploads in graphql-upload way', async () => { - const app = express(); - app.use( - testPath, - graphqlUploadExpress({ maxFileSize: 10000000, maxFiles: 10 }), - createServer({ - schema: testSchema, - }) - ); - httpServer = http.createServer(app); - httpServer.listen(3000); + httpServer = http.createServer(function weServeSocketsOnly(_, res) { + res.writeHead(404); + res.end(); + }); - const [{ schema }] = await loader.load(testUrl, { - multipart: true, - }); + await new Promise(resolve => httpServer.listen(8081, resolve)); - const fileName = 'testfile.txt'; + const subscriptionServer = SubscriptionServer.create( + { + schema: testSchema, + execute, + subscribe, + }, + { + server: httpServer, + path: '/graphql', + } + ); + assertNonMaybe(schema); + const asyncIterator = (await subscribe({ + schema, + document: parse(/* GraphQL */ ` + subscription TestMessage { + testMessage { + number + } + } + `), + contextValue: {}, + })) as AsyncIterableIterator; - const absoluteFilePath = join(__dirname, fileName); + expect(asyncIterator['errors']).toBeFalsy(); + expect(asyncIterator['errors']?.length).toBeFalsy(); - const content = readFileSync(absoluteFilePath, 'utf8'); - assertNonMaybe(schema); - const result = await execute({ - schema, - document: parse(/* GraphQL */ ` - mutation UploadFile($file: Upload!, $nullVar: TestInput, $nonObjectVar: String) { - uploadFile(file: $file, dummyVar: $nullVar, secondDummyVar: $nonObjectVar) { - filename - content - } - } - `), - variableValues: { - file: new File([content], fileName, { type: 'text/plain' }), - nullVar: null, - nonObjectVar: 'somefilename.txt', - }, - }); + async function getNextResult() { + const result = await asyncIterator.next(); + expect(result?.done).toBeFalsy(); + return result?.value?.data?.testMessage?.number; + } - expect(result.errors).toBeFalsy(); - assertNonMaybe(result.data); - const uploadFileData: any = result.data?.['uploadFile']; - expect(uploadFileData?.filename).toBe(fileName); - expect(uploadFileData?.content).toBe(content); - }); + expect(await getNextResult()).toBe(0); + expect(await getNextResult()).toBe(1); + expect(await getNextResult()).toBe(2); - describe('helix/yoga compat', () => { - it('should handle multipart response result', async () => { - const chunkDatas = [ - { data: { foo: {} }, hasNext: true }, - { data: { a: 1 }, path: ['foo'], hasNext: true }, - { data: { a: 1, b: 2 }, path: ['foo'], hasNext: false }, - ]; - const expectedDatas: ExecutionResult[] = [ - { - data: { - foo: {}, - }, - }, - { - data: { - foo: { - a: 1, - }, - }, - }, + await asyncIterator.return!(); + subscriptionServer.close(); + }); + it('should handle subscriptions - graphql-sse', async () => { + const testUrl = 'http://localhost:8081/graphql'; + const customFetch: AsyncFetchFn = async (url, options) => { + if (String(options?.body).includes('IntrospectionQuery')) { + return new Response( + JSON.stringify({ + data: introspectionFromSchema(testSchema), + }), { - data: { - foo: { - a: 1, - b: 2, - }, + headers: { + 'content-type': 'application/json', }, - }, - ]; - const serverPort = 1335; - const serverHost = 'http://localhost:' + serverPort; - httpServer = http.createServer((_, res) => { - res.writeHead(200, { - // prettier-ignore - "Connection": "keep-alive", - 'Content-Type': 'multipart/mixed; boundary="-"', - 'Transfer-Encoding': 'chunked', - }); - - res.write(`---`); - - chunkDatas.forEach(chunkData => - sleep(300).then(() => { - const chunk = Buffer.from(JSON.stringify(chunkData), 'utf8'); - const data = ['', 'Content-Type: application/json; charset=utf-8', '', chunk, '', `---`]; - res.write(data.join('\r\n')); - }) - ); - - sleep(1000).then(() => { - res.write('\r\n-----\r\n'); - res.end(); - }); - }); - await new Promise(resolve => httpServer.listen(serverPort, resolve)); - - const executor = loader.getExecutorAsync(serverHost); - const result = await executor({ - document: parse(/* GraphQL */ ` - query { - foo { - ... on Foo @defer { - a - b - } - } - } - `), - }); - - assertAsyncIterable(result); - for await (const data of result) { - expect(data).toEqual(expectedDatas.shift()!); - } - expect(expectedDatas.length).toBe(0); - }); + } + ); + } + return defaultAsyncFetch(url, options); + }; - it('should handle SSE subscription result', async () => { - const expectedDatas: ExecutionResult[] = [{ data: { foo: 1 } }, { data: { foo: 2 } }, { data: { foo: 3 } }]; - const serverPort = 1336; - const serverHost = 'http://localhost:' + serverPort; - - httpServer = http.createServer((_, res) => { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - // prettier-ignore - "Connection": "keep-alive", - 'Cache-Control': 'no-cache', - }); - - expectedDatas.forEach(result => sleep(300).then(() => res.write(`data: ${JSON.stringify(result)}\n\n`))); - - sleep(1000).then(() => res.end()); - }); - - await new Promise(resolve => httpServer.listen(serverPort, () => resolve())); - - const executor = loader.getExecutorAsync(`${serverHost}/graphql`, { - subscriptionsProtocol: SubscriptionProtocol.SSE, - }); - const result = await executor({ - document: parse(/* GraphQL */ ` - subscription { - foo - } - `), - }); - assertAsyncIterable(result); - - for await (const singleResult of result) { - expect(singleResult).toStrictEqual(expectedDatas.shift()!); - } - expect(expectedDatas.length).toBe(0); - }); - it('terminates SSE subscriptions when calling return on the AsyncIterable', async () => { - const sentDatas: ExecutionResult[] = [ - { data: { foo: 1 } }, - { data: { foo: 2 } }, - { data: { foo: 3 } }, - { data: { foo: 4 } }, - ]; - const serverPort = 1336; - const serverHost = 'http://localhost:' + serverPort; - - let serverResponseEnded$: Promise; - httpServer = http.createServer((_, res) => { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - // prettier-ignore - "Connection": "keep-alive", - 'Cache-Control': 'no-cache', - }); - - const ping = setInterval(() => { - // Ping - res.write(':\n\n'); - }, 50); - sentDatas.forEach(result => sleep(300).then(() => res.write(`data: ${JSON.stringify(result)}\n\n`))); - serverResponseEnded$ = new Promise(resolve => - res.once('close', () => { - resolve(true); - clearInterval(ping); - }) - ); - }); - - await new Promise(resolve => httpServer.listen(serverPort, () => resolve())); - - const executor = loader.getExecutorAsync(`${serverHost}/graphql`, { - subscriptionsProtocol: SubscriptionProtocol.SSE, - }); - const result = await executor({ - document: parse(/* GraphQL */ ` - subscription { - foo - } - `), - }); - assertAsyncIterable(result); - - const iterator = result[Symbol.asyncIterator](); - - const firstResult = await iterator.next(); - expect(firstResult.value).toStrictEqual(sentDatas[0]); - const secondResult = await iterator.next(); - expect(secondResult.value).toStrictEqual(sentDatas[1]); - // Stop the request - await iterator.return?.(); - const doneResult = await iterator.next(); - expect(doneResult).toStrictEqual({ done: true, value: undefined }); - expect(await serverResponseEnded$!).toBe(true); - }); + const [{ schema }] = await loader.load(testUrl, { + customFetch, + subscriptionsProtocol: SubscriptionProtocol.GRAPHQL_SSE, }); - it('should handle aliases properly', async () => { - const yoga = createServer({ + httpServer = http.createServer( + createHandler({ schema: testSchema, - port: 9876, - logging: false, - }); - httpServer = await yoga.start(); - const schema = await loadSchema(`http://0.0.0.0:9876/graphql`, { - loaders: [loader], - }); - const document = parse(/* GraphQL */ ` - query { - b: a - foo: complexField(complexArg: { id: "FOO" }) { - id - bar: complexChildren(complexChildArg: { id: "BAR" }) { - id - } + }) + ); + await new Promise(resolve => httpServer.listen(8081, resolve)); + + assertNonMaybe(schema); + const asyncIterable = (await subscribe({ + schema, + document: parse(/* GraphQL */ ` + subscription TestMessage { + testMessage { + number } } - `); - const result: any = await execute({ - schema, - document, - }); - expect(result?.data?.['b']).toBe('a'); - expect(result?.data?.['foo']?.id).toBe('FOO'); - expect(result?.data?.['foo']?.bar?.[0]?.id).toBe('BAR'); - }); + `), + contextValue: {}, + })) as AsyncIterable>; - describe('sync', () => { - it('should handle introspection', () => { - const [{ schema }] = loader.loadSync(`https://swapi-graphql.netlify.app/.netlify/functions/index`, {}); - expect(schema).toBeInstanceOf(GraphQLSchema); - expect(printSchemaWithDirectives(schema!).trim()).toMatchSnapshot(); - }); - it('should handle queries', () => { - const [{ schema }] = loader.loadSync(`https://swapi-graphql.netlify.app/.netlify/functions/index`, {}); - const result = graphqlSync({ - schema: schema!, - source: /* GraphQL */ ` - { - allFilms { - totalCount - } - } - `, - }); - expect(result).toMatchSnapshot(); - }); - }); + expect(asyncIterable['errors']).toBeFalsy(); + expect(asyncIterable['errors']?.length).toBeFalsy(); + + let i = 0; + for await (const result of asyncIterable) { + expect(result?.data?.testMessage?.number).toBe(i); + i++; + } + + expect.assertions(5); }); - describe.skip('yoga', () => { - const urlLoader = new UrlLoader(); - let yogaApp: ReturnType; - let interval: any; - let cnt = 0; - beforeAll(() => { - const liveQueryStore = new InMemoryLiveQueryStore(); - interval = setInterval(() => { - cnt++; - liveQueryStore.invalidate('Query.cnt'); - }, 100); - yogaApp = createServer({ - schema: { - typeDefs: [ - /* GraphQL */ ` - type Query { - cnt: Int! - } - `, - GraphQLLiveDirectiveSDL, - ], - resolvers: { - Query: { - cnt: () => cnt, + it('should handle aliases properly', async () => { + const customFetch: AsyncFetchFn = async (_, options) => { + const bodyStr = String(options?.body); + if (bodyStr.includes('IntrospectionQuery')) { + return new Response( + JSON.stringify({ + data: introspectionFromSchema(testSchema), + }), + { + headers: { + 'content-type': 'application/json', }, + } + ); + } + return new Response( + JSON.stringify( + await execute({ + schema: testSchema, + document: parse(JSON.parse(bodyStr).query), + }) + ), + { + headers: { + 'content-type': 'application/json', }, - }, - plugins: [ - useLiveQuery({ - liveQueryStore, - }), - ], - logging: false, - port: 9876, - }); - yogaApp.start(); - }); - afterAll(() => { - clearInterval(interval); - yogaApp.stop(); + } + ); + }; + const schema = await loadSchema(`http://0.0.0.0:8081/graphql`, { + loaders: [loader], + customFetch, }); - it('should handle live queries', async () => { - const executor = urlLoader.getExecutorAsync(yogaApp.getServerUrl(), { - subscriptionsProtocol: SubscriptionProtocol.SSE, - }); - const result = await executor({ - document: parse(/* GraphQL */ ` - query Count @live { - cnt + const document = parse(/* GraphQL */ ` + query TestQuery { + b: a + foo: complexField(complexArg: { id: "FOO" }) { + id + bar: complexChildren(complexChildArg: { id: "BAR" }) { + id } - `), - }); - assertAsyncIterable(result); - for await (const singleResult of result) { - expect(singleResult.data.cnt).toBe(cnt); - expect((singleResult as LiveExecutionResult).isLive); - if (cnt >= 3) { - break; } } + `); + const result: any = await execute({ + schema, + document, }); + expect(result?.data?.['b']).toBe('a'); + expect(result?.data?.['foo']?.id).toBe('FOO'); + expect(result?.data?.['foo']?.bar?.[0]?.id).toBe('BAR'); }); }); - -function assertAsyncIterable(input: unknown): asserts input is AsyncIterable { - if (!isAsyncIterable(input)) { - throw new Error(`Expected AsyncIterable. but received: ${inspect(input)}`); - } -} - -function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); -} diff --git a/yarn.lock b/yarn.lock index b685fd9921b..71f712b7719 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2295,13 +2295,6 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.0.tgz#ea89004119dc42db2e1dba0f97d553f7372f6fcb" integrity sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg== -"@envelop/core@^2.4.0": - version "2.4.0" - resolved "https://registry.yarnpkg.com/@envelop/core/-/core-2.4.0.tgz#4319bd681a83fa0964c8be77f7535f728e2e05bb" - integrity sha512-ZA8d3sg69+IrZH0seB/HxHi+IYaOOu5OLzZBAHw7MrT2mBI0RyboM6QZv0gGvYJjQIUeJ1I2Zz4bqjOKNmgpxA== - dependencies: - "@envelop/types" "2.3.0" - "@envelop/live-query@4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@envelop/live-query/-/live-query-4.0.1.tgz#69784353f216faf9ac4397e47c1d9295611f0449" @@ -2311,25 +2304,6 @@ "@n1ru4l/graphql-live-query" "^0.10.0" "@n1ru4l/in-memory-live-query-store" "^0.10.0" -"@envelop/parser-cache@^4.4.0": - version "4.4.0" - resolved "https://registry.yarnpkg.com/@envelop/parser-cache/-/parser-cache-4.4.0.tgz#be4685b8fbed46a54ba740ae8e264a35328637ac" - integrity sha512-m/RhbWlkKmRftqytWfBGLJCtiUqSOb3gxXsk4/TOlABZZ8KPeI4Yiq5SwZh6UrFOaup00QZK+fWmfjdniLi/sA== - dependencies: - tiny-lru "7.0.6" - -"@envelop/types@2.3.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@envelop/types/-/types-2.3.0.tgz#d633052eb3c7e7913380165ce041e2c0e358d5b6" - integrity sha512-K20KxpGlY+HKenms4Kccuh/YcCm0ytj1Zk0Ak6b6hKA68JI4YQ2GwGMq7A7gSOb1MxlbKqrnVdeQdXaDpQfCmA== - -"@envelop/validation-cache@^4.4.0": - version "4.4.0" - resolved "https://registry.yarnpkg.com/@envelop/validation-cache/-/validation-cache-4.4.0.tgz#4700415730a6e3cd5114aaf2c2f557d0fe8bde5f" - integrity sha512-mvKc1GU9xtQC1yKYYoEbkmjXXEGB7U/ESqlFO33oaMB7z3ctoQpvldBSJj+xj3hF9OchMb1nQKKSYf0GYpF/Gw== - dependencies: - tiny-lru "7.0.6" - "@esbuild/linux-loong64@0.15.0": version "0.15.0" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.0.tgz#7ca859765361a92a60669a1794e9c1837d427d97" @@ -2363,7 +2337,7 @@ "@graphql-tools/utils" "8.9.0" tslib "^2.4.0" -"@graphql-tools/schema@^8.0.0", "@graphql-tools/schema@^8.5.0": +"@graphql-tools/schema@^8.0.0": version "8.5.1" resolved "https://registry.yarnpkg.com/@graphql-tools/schema/-/schema-8.5.1.tgz#c2f2ff1448380919a330312399c9471db2580b58" integrity sha512-0Esilsh0P/qYcB5DKQpiKeQs/jevzIadNTaT0jeWklPMwNbT7yMX4EqZany7mbeRRlSRwMzNzL5olyFdffHBZg== @@ -2385,51 +2359,6 @@ resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.1.1.tgz#076d78ce99822258cf813ecc1e7fa460fa74d052" integrity sha512-NQ17ii0rK1b34VZonlmT2QMJFI70m0TRwbknO/ihlbatXyaktDhN/98vBiUU6kNBPljqGqyIrl2T4nY2RpFANg== -"@graphql-yoga/common@^2.12.5": - version "2.12.5" - resolved "https://registry.yarnpkg.com/@graphql-yoga/common/-/common-2.12.5.tgz#930cf3599a3398f6a56f2f1755e6c9785e60bedb" - integrity sha512-5p634xtfphiHNPd39kyDc3VSIX8shkinywalNiMHEAal1DGYCn4x/mKbA6Q7tgWvrCqC4fAAuklHoxxi7TIyxA== - dependencies: - "@envelop/core" "^2.4.0" - "@envelop/parser-cache" "^4.4.0" - "@envelop/validation-cache" "^4.4.0" - "@graphql-tools/schema" "^8.5.0" - "@graphql-tools/utils" "^8.8.0" - "@graphql-typed-document-node/core" "^3.1.1" - "@graphql-yoga/subscription" "^2.2.3" - "@whatwg-node/fetch" "^0.2.6" - dset "^3.1.1" - tslib "^2.3.1" - -"@graphql-yoga/node@2.13.6": - version "2.13.6" - resolved "https://registry.yarnpkg.com/@graphql-yoga/node/-/node-2.13.6.tgz#536b9e9b63d56c87e4886dd86808eab8ff83a979" - integrity sha512-gQDP2pZ88lR6+ZZbq6VosiCmhKR9cMWIAbtjTqcFu5k3isKdJnJvCSzk8vZuMjdwWl+IlyJaTVZTN8Xor+Z2tQ== - dependencies: - "@envelop/core" "^2.4.0" - "@graphql-tools/utils" "^8.8.0" - "@graphql-yoga/common" "^2.12.5" - "@graphql-yoga/subscription" "^2.2.3" - "@whatwg-node/fetch" "^0.2.6" - tslib "^2.3.1" - -"@graphql-yoga/subscription@^2.2.3": - version "2.2.3" - resolved "https://registry.yarnpkg.com/@graphql-yoga/subscription/-/subscription-2.2.3.tgz#e378fa17a12675ae7b34b2f51e39bc02df312ba0" - integrity sha512-It/Dfh+nW2ClTtmOylAa+o7fbKIRYRTH6jfKLj3YB75tkv/rFZ70bjlChDCrEMa46I+zDMg7+cdkrQOXov2fDg== - dependencies: - "@graphql-yoga/typed-event-target" "^0.1.1" - "@repeaterjs/repeater" "^3.0.4" - tslib "^2.3.1" - -"@graphql-yoga/typed-event-target@^0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@graphql-yoga/typed-event-target/-/typed-event-target-0.1.1.tgz#248d56a76046d805af8c0da3ef590cdb95d2c192" - integrity sha512-l23kLKHKhfD7jmv4OUlzxMTihSqgIjGWCSb0KdlLkeiaF2jjuo8pRhX200hFTrtjRHGSYS1fx2lltK/xWci+vw== - dependencies: - "@repeaterjs/repeater" "^3.0.4" - tslib "^2.3.1" - "@guild-docs/algolia@0.2.2": version "0.2.2" resolved "https://registry.yarnpkg.com/@guild-docs/algolia/-/algolia-0.2.2.tgz#cd85e0e9d3db5fc16c9f97f64ba46f7d9eda0256" @@ -4312,10 +4241,10 @@ "@webassemblyjs/ast" "1.11.1" "@xtuc/long" "4.2.2" -"@whatwg-node/fetch@^0.2.4", "@whatwg-node/fetch@^0.2.6": - version "0.2.6" - resolved "https://registry.yarnpkg.com/@whatwg-node/fetch/-/fetch-0.2.6.tgz#eb8e68624c55aecfa4c6d7ea36b3ce3ad5d5f79e" - integrity sha512-NhHiqeGcKjgqUZvJTZSou9qsFEPBBG1LPm2Npz0cmcPvukhhQfjX+p3quRx6b9AyjNPp1f73VB1z4ApHy9FcNg== +"@whatwg-node/fetch@^0.2.9": + version "0.2.9" + resolved "https://registry.yarnpkg.com/@whatwg-node/fetch/-/fetch-0.2.9.tgz#0f0e72f79957a0544d2a9455082802d87be93ffe" + integrity sha512-h+ehuqE/ZqJdRy+xywHyKpBIPmST0ms8Itgf4gGSu10pJrmod3/t9DbG/GlATvLBE4pvqYHrxKAKo3NNQVJc3g== dependencies: "@peculiar/webcrypto" "^1.4.0" abort-controller "^3.0.0" @@ -6441,7 +6370,7 @@ dset@^2.0.1: resolved "https://registry.yarnpkg.com/dset/-/dset-2.1.0.tgz#cd1e99e55cf32366d8f144f906c42f7fb3bf431e" integrity sha512-hlQYwNEdW7Qf8zxysy+yN1E8C/SxRst3Z9n+IvXOR35D9bPVwNHhnL8ZBeoZjvinuGrlvGg6pAMDwhmjqFDgjA== -dset@^3.1.1, dset@^3.1.2: +dset@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.2.tgz#89c436ca6450398396dc6538ea00abc0c54cd45a" integrity sha512-g/M9sqy3oHe477Ar4voQxWtaPIFw1jTdKZuomOjhCcBx9nHUNn0pu6NopuFFrTh/TRZIKEj+76vLWFu9BNKk+Q== @@ -13280,11 +13209,6 @@ tiny-invariant@^1.0.6: resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== -tiny-lru@7.0.6: - version "7.0.6" - resolved "https://registry.yarnpkg.com/tiny-lru/-/tiny-lru-7.0.6.tgz#b0c3cdede1e5882aa2d1ae21cb2ceccf2a331f24" - integrity sha512-zNYO0Kvgn5rXzWpL0y3RS09sMK67eGaQj9805jlK9G6pSadfriTczzLHFXa/xcW4mIRfmlB9HyQ/+SgL0V1uow== - tiny-lru@8.0.2: version "8.0.2" resolved "https://registry.yarnpkg.com/tiny-lru/-/tiny-lru-8.0.2.tgz#812fccbe6e622ded552e3ff8a4c3b5ff34a85e4c"