From f2bb0995e64671135bc6de25bc02010d9b61e616 Mon Sep 17 00:00:00 2001 From: Alessia Bellisario Date: Thu, 8 Sep 2022 18:10:46 -0400 Subject: [PATCH] Adds @defer support (#10018) Co-authored-by: Ben Newman --- .gitignore | 8 +- .vscode/settings.json | 2 +- CHANGELOG.md | 5 + package-lock.json | 66 +- package.json | 8 +- src/__tests__/__snapshots__/exports.ts.snap | 1 + src/core/ObservableQuery.ts | 2 +- src/core/QueryInfo.ts | 27 +- src/core/QueryManager.ts | 21 +- src/link/core/types.ts | 74 +- src/link/http/__tests__/HttpLink.ts | 238 ++++++ src/link/http/__tests__/responseIterator.ts | 510 ++++++++++++ .../responseIteratorNoAsyncIterator.ts | 356 +++++++++ src/link/http/createHttpLink.ts | 69 +- src/link/http/iterators/LICENSE | 21 + src/link/http/iterators/async.ts | 18 + src/link/http/iterators/nodeStream.ts | 94 +++ src/link/http/iterators/promise.ts | 43 + src/link/http/iterators/reader.ts | 29 + src/link/http/parseAndCheckHttpResponse.ts | 233 +++++- src/link/http/responseIterator.ts | 47 ++ src/react/hooks/__tests__/useQuery.test.tsx | 732 +++++++++++++++++- src/testing/core/subscribeAndCount.ts | 29 +- src/utilities/common/canUse.ts | 2 + src/utilities/common/incrementalResult.ts | 5 + src/utilities/common/responseIterator.ts | 32 + src/utilities/graphql/directives.ts | 19 +- 27 files changed, 2557 insertions(+), 134 deletions(-) create mode 100644 src/link/http/__tests__/responseIterator.ts create mode 100644 src/link/http/__tests__/responseIteratorNoAsyncIterator.ts create mode 100644 src/link/http/iterators/LICENSE create mode 100644 src/link/http/iterators/async.ts create mode 100644 src/link/http/iterators/nodeStream.ts create mode 100644 src/link/http/iterators/promise.ts create mode 100644 src/link/http/iterators/reader.ts create mode 100644 src/link/http/responseIterator.ts create mode 100644 src/utilities/common/incrementalResult.ts create mode 100644 src/utilities/common/responseIterator.ts diff --git a/.gitignore b/.gitignore index a2688e5c01d..c133c77c1e3 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ pids # Directory for instrumented libs generated by jscoverage/JSCover lib-cov +# Ignore Wallaby.js configuration file +wallaby.js + # Coverage directory used by tools like istanbul coverage @@ -61,4 +64,7 @@ junit.xml .rpt2_cache # Local Netlify folder -.netlify \ No newline at end of file +.netlify + +# Ignore generated test report output +reports diff --git a/.vscode/settings.json b/.vscode/settings.json index 4ab27d98c2e..f8ace00ded3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,7 +4,7 @@ "editor.rulers": [80], "files.trimTrailingWhitespace": true, "files.insertFinalNewline": true, - "typescript.tsdk": "../node_modules/typescript/lib", + "typescript.tsdk": "node_modules/typescript/lib", "cSpell.enableFiletypes": [ "mdx" ] diff --git a/CHANGELOG.md b/CHANGELOG.md index 77c7360e02a..27b30b43414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ - Delay calling `onCompleted` and `onError` callbacks passed to `useQuery` using `Promise.resolve().then(() => ...)` to fix issue [#9794](https://github.com/apollographql/apollo-client/pull/9794).
[@dylanwulf](https://github.com/dylanwulf) in [#9823](https://github.com/apollographql/apollo-client/pull/9823) +### Potentially disruptive + +- The optional `subscribeAndCount` testing utility exported from `@apollo/client/testing/core` now takes a single generic `TResult` type parameter, instead of `TData`. This type will typically be inferred from the `observable` argument type, but if you have any explicit calls to `subscribeAndCount(...)` in your own codebase, you may need to adjust those calls accordingly.
+ [@benjamn](https://github.com/benjamn) in [#9718](https://github.com/apollographql/apollo-client/pull/9718) + ## Apollo Client 3.6.9 (2022-06-21) ### Bug Fixes diff --git a/package-lock.json b/package-lock.json index c86738b59c5..4f0d8b046c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "hoist-non-react-statics": "^3.3.2", "optimism": "^0.16.1", "prop-types": "^15.7.2", + "response-iterator": "^0.2.6", "symbol-observable": "^4.0.0", "ts-invariant": "^0.10.3", "tslib": "^2.3.0", @@ -36,10 +37,12 @@ "@types/jest": "27.5.2", "@types/lodash": "4.14.182", "@types/node": "16.11.41", + "@types/node-fetch": "2.6.2", "@types/react": "17.0.47", "@types/react-dom": "17.0.17", - "@types/use-sync-external-store": "^0.0.3", + "@types/use-sync-external-store": "0.0.3", "acorn": "8.7.1", + "blob-polyfill": "7.0.20220408", "bundlesize": "0.18.1", "cross-fetch": "3.1.5", "crypto-hash": "1.3.0", @@ -65,6 +68,7 @@ "ts-node": "10.8.1", "typescript": "4.6.4", "wait-for-observables": "1.0.3", + "web-streams-polyfill": "3.2.1", "whatwg-fetch": "3.6.2" }, "engines": { @@ -1389,6 +1393,16 @@ "integrity": "sha512-mqoYK2TnVjdkGk8qXAVGc/x9nSaTpSrFaGFm43BUH3IdoBV0nta6hYaGmdOvIMlbHJbUEVen3gvwpwovAZKNdQ==", "dev": true }, + "node_modules/@types/node-fetch": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz", + "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==", + "dev": true, + "dependencies": { + "@types/node": "*", + "form-data": "^3.0.0" + } + }, "node_modules/@types/prettier": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.4.tgz", @@ -1822,6 +1836,12 @@ "node": ">= 6" } }, + "node_modules/blob-polyfill": { + "version": "7.0.20220408", + "resolved": "https://registry.npmjs.org/blob-polyfill/-/blob-polyfill-7.0.20220408.tgz", + "integrity": "sha512-oD8Ydw+5lNoqq+en24iuPt1QixdPpe/nUF8azTHnviCZYu9zUC+TwdzIp5orpblJosNlgNbVmmAb//c6d6ImUQ==", + "dev": true + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -5271,6 +5291,14 @@ "node": ">=10" } }, + "node_modules/response-iterator": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/response-iterator/-/response-iterator-0.2.6.tgz", + "integrity": "sha512-pVzEEzrsg23Sh053rmDUvLSkGXluZio0qu8VT6ukrYuvtjVfCbDZH9d6PGXb8HZfzdNZt8feXv/jvUzlhRgLnw==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -6174,6 +6202,15 @@ "makeerror": "1.0.12" } }, + "node_modules/web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", @@ -7524,6 +7561,16 @@ "integrity": "sha512-mqoYK2TnVjdkGk8qXAVGc/x9nSaTpSrFaGFm43BUH3IdoBV0nta6hYaGmdOvIMlbHJbUEVen3gvwpwovAZKNdQ==", "dev": true }, + "@types/node-fetch": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz", + "integrity": "sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==", + "dev": true, + "requires": { + "@types/node": "*", + "form-data": "^3.0.0" + } + }, "@types/prettier": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.4.tgz", @@ -7875,6 +7922,12 @@ } } }, + "blob-polyfill": { + "version": "7.0.20220408", + "resolved": "https://registry.npmjs.org/blob-polyfill/-/blob-polyfill-7.0.20220408.tgz", + "integrity": "sha512-oD8Ydw+5lNoqq+en24iuPt1QixdPpe/nUF8azTHnviCZYu9zUC+TwdzIp5orpblJosNlgNbVmmAb//c6d6ImUQ==", + "dev": true + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -10549,6 +10602,11 @@ "integrity": "sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==", "dev": true }, + "response-iterator": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/response-iterator/-/response-iterator-0.2.6.tgz", + "integrity": "sha512-pVzEEzrsg23Sh053rmDUvLSkGXluZio0qu8VT6ukrYuvtjVfCbDZH9d6PGXb8HZfzdNZt8feXv/jvUzlhRgLnw==" + }, "rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -11232,6 +11290,12 @@ "makeerror": "1.0.12" } }, + "web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "dev": true + }, "webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", diff --git a/package.json b/package.json index 7e0df8c8f7a..cfd95948066 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.min.cjs", - "maxSize": "29.95kB" + "maxSize": "31.4kB" } ], "engines": { @@ -88,6 +88,7 @@ "hoist-non-react-statics": "^3.3.2", "optimism": "^0.16.1", "prop-types": "^15.7.2", + "response-iterator": "^0.2.6", "symbol-observable": "^4.0.0", "ts-invariant": "^0.10.3", "tslib": "^2.3.0", @@ -107,10 +108,12 @@ "@types/jest": "27.5.2", "@types/lodash": "4.14.182", "@types/node": "16.11.41", + "@types/node-fetch": "2.6.2", "@types/react": "17.0.47", "@types/react-dom": "17.0.17", - "@types/use-sync-external-store": "^0.0.3", + "@types/use-sync-external-store": "0.0.3", "acorn": "8.7.1", + "blob-polyfill": "7.0.20220408", "bundlesize": "0.18.1", "cross-fetch": "3.1.5", "crypto-hash": "1.3.0", @@ -136,6 +139,7 @@ "ts-node": "10.8.1", "typescript": "4.6.4", "wait-for-observables": "1.0.3", + "web-streams-polyfill": "3.2.1", "whatwg-fetch": "3.6.2" }, "publishConfig": { diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 9de8e082f4e..2c65b769b11 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -350,6 +350,7 @@ Array [ "argumentsObjectFromField", "asyncMap", "buildQueryFromSelectionSet", + "canUseAsyncIteratorSymbol", "canUseDOM", "canUseLayoutEffect", "canUseSymbol", diff --git a/src/core/ObservableQuery.ts b/src/core/ObservableQuery.ts index f896b927483..c1b1e255409 100644 --- a/src/core/ObservableQuery.ts +++ b/src/core/ObservableQuery.ts @@ -482,7 +482,7 @@ Did you mean to call refetch(variables) instead of refetch({ variables })?`); }, }); - return fetchMoreResult as ApolloQueryResult; + return fetchMoreResult; }).finally(() => { // In case the cache writes above did not generate a broadcast diff --git a/src/core/QueryInfo.ts b/src/core/QueryInfo.ts index a5dbfecba47..bc2a7bc9118 100644 --- a/src/core/QueryInfo.ts +++ b/src/core/QueryInfo.ts @@ -2,6 +2,7 @@ import { DocumentNode, GraphQLError } from 'graphql'; import { equal } from "@wry/equality"; import { Cache, ApolloCache } from '../cache'; +import { DeepMerger } from "../utilities" import { WatchQueryOptions, ErrorPolicy } from './watchQueryOptions'; import { ObservableQuery, reobserveCacheFirst } from './ObservableQuery'; import { QueryListener } from './types'; @@ -153,7 +154,6 @@ export class QueryInfo { reset() { cancelNotifyTimeout(this); - this.lastDiff = void 0; this.dirty = false; } @@ -362,12 +362,35 @@ export class QueryInfo { | "errorPolicy">, cacheWriteBehavior: CacheWriteBehavior, ) { - this.graphQLErrors = isNonEmptyArray(result.errors) ? result.errors : []; + const graphQLErrors = isNonEmptyArray(result.errors) + ? result.errors.slice(0) + : []; // Cancel the pending notify timeout (if it exists) to prevent extraneous network // requests. To allow future notify timeouts, diff and dirty are reset as well. this.reset(); + if ('incremental' in result && isNonEmptyArray(result.incremental)) { + let mergedData = this.getDiff().result; + const merger = new DeepMerger(); + result.incremental.forEach(({ data, path, errors }) => { + for (let i = path.length - 1; i >= 0; --i) { + const key = path[i]; + const isNumericKey = !isNaN(+key); + const parent: Record = isNumericKey ? [] : {}; + parent[key] = data; + data = parent as typeof data; + } + if (errors) { + graphQLErrors.push(...errors); + } + mergedData = merger.merge(mergedData, data); + }); + result.data = mergedData; + } + + this.graphQLErrors = graphQLErrors; + if (options.fetchPolicy === 'no-cache') { this.updateLastDiff( { result: result.data, complete: true }, diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index ee4c31edc1b..04bec55c54e 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -6,6 +6,7 @@ type OperationTypeNode = any; import { equal } from '@wry/equality'; import { ApolloLink, execute, FetchResult } from '../link/core'; +import { isExecutionPatchIncrementalResult } from '../utilities/common/incrementalResult'; import { Cache, ApolloCache, canonicalStringify } from '../cache'; import { @@ -434,7 +435,7 @@ export class QueryManager { returnPartialData: true, }); - if (diff.complete) { + if (diff.complete && !(isExecutionPatchIncrementalResult(result))) { result = { ...result, data: diff.result }; } } @@ -1047,7 +1048,19 @@ export class QueryManager { ), result => { - const hasErrors = isNonEmptyArray(result.errors); + const graphQLErrors = isNonEmptyArray(result.errors) + ? result.errors.slice(0) + : []; + + if ('incremental' in result && isNonEmptyArray(result.incremental)) { + result.incremental.forEach(incrementalResult => { + if (incrementalResult.errors) { + graphQLErrors.push(...incrementalResult.errors); + } + }); + } + + const hasErrors = isNonEmptyArray(graphQLErrors); // If we interrupted this request by calling getResultsFromLink again // with the same QueryInfo object, we ignore the old results. @@ -1055,7 +1068,7 @@ export class QueryManager { if (hasErrors && options.errorPolicy === "none") { // Throwing here effectively calls observer.error. throw queryInfo.markError(new ApolloError({ - graphQLErrors: result.errors, + graphQLErrors, })); } queryInfo.markResult(result, options, cacheWriteBehavior); @@ -1069,7 +1082,7 @@ export class QueryManager { }; if (hasErrors && options.errorPolicy !== "ignore") { - aqr.errors = result.errors; + aqr.errors = graphQLErrors; aqr.networkStatus = NetworkStatus.error; } diff --git a/src/link/core/types.ts b/src/link/core/types.ts index 847cf626816..a81038da00a 100644 --- a/src/link/core/types.ts +++ b/src/link/core/types.ts @@ -1,7 +1,60 @@ -import { DocumentNode, ExecutionResult } from 'graphql'; +import { DocumentNode, ExecutionResult, GraphQLError } from "graphql"; export { DocumentNode }; -import { Observable } from '../../utilities'; +import { Observable } from "../../utilities"; + +export type Path = ReadonlyArray; +type Data = T | null | undefined; + + +interface ExecutionPatchResultBase { + hasNext?: boolean; +} + +export interface ExecutionPatchInitialResult< + TData = Record, + TExtensions = Record +> extends ExecutionPatchResultBase { + // if data is present, incremental is not + data: Data; + incremental?: never; + errors?: ReadonlyArray; + extensions?: TExtensions; +} + +export interface IncrementalPayload< + TData, + TExtensions, +> { + // data and path must both be present + // https://github.com/graphql/graphql-spec/pull/742/files#diff-98d0cd153b72b63c417ad4238e8cc0d3385691ccbde7f7674bc0d2a718b896ecR288-R293 + data: Data; + label?: string; + path: Path; + errors?: ReadonlyArray; + extensions?: TExtensions; +} + +export interface ExecutionPatchIncrementalResult< + TData = Record, + TExtensions = Record +> extends ExecutionPatchResultBase { + // the reverse is also true: if incremental is present, + // data (and errors and extensions) are not + incremental?: IncrementalPayload[]; + data?: never; + // Errors only exist for chunks, not at the top level + // https://github.com/robrichard/defer-stream-wg/discussions/50#discussioncomment-3466739 + errors?: never; + extensions?: never; +} + +export type ExecutionPatchResult< + TData = Record, + TExtensions = Record +> = + | ExecutionPatchInitialResult + | ExecutionPatchIncrementalResult; export interface GraphQLRequest { query: DocumentNode; @@ -20,19 +73,26 @@ export interface Operation { getContext: () => Record; } -export interface FetchResult< +export interface SingleExecutionResult< TData = Record, TContext = Record, TExtensions = Record > extends ExecutionResult { - data?: TData | null | undefined; - extensions?: TExtensions; + data?: Data; context?: TContext; -}; +} + +export type FetchResult< + TData = Record, + TContext = Record, + TExtensions = Record +> = + | SingleExecutionResult + | ExecutionPatchResult; export type NextLink = (operation: Operation) => Observable; export type RequestHandler = ( operation: Operation, - forward: NextLink, + forward: NextLink ) => Observable | null; diff --git a/src/link/http/__tests__/HttpLink.ts b/src/link/http/__tests__/HttpLink.ts index 7114770ae32..eefa00607d8 100644 --- a/src/link/http/__tests__/HttpLink.ts +++ b/src/link/http/__tests__/HttpLink.ts @@ -1,6 +1,9 @@ import gql from 'graphql-tag'; import fetchMock from 'fetch-mock'; import { ASTNode, print, stripIgnoredCharacters } from 'graphql'; +import { TextDecoder } from 'util'; +import { ReadableStream } from 'web-streams-polyfill/ponyfill/es2018'; +import { Readable } from 'stream'; import { Observable, Observer, ObservableSubscription } from '../../../utilities/observables/Observable'; import { ApolloLink } from '../../core/ApolloLink'; @@ -29,6 +32,28 @@ const sampleMutation = gql` } `; +const sampleDeferredQuery = gql` + query SampleDeferredQuery { + stub { + id + ... on Stub @defer { + name + } + } + } +`; + +const sampleQueryCustomDirective = gql` + query SampleDeferredQuery { + stub { + id + ... on Stub @deferCustomDirective { + name + } + } + } +`; + function makeCallback( resolve: () => void, reject: (error: Error) => void, @@ -1248,4 +1273,217 @@ describe('HttpLink', () => { ); }); }); + + describe('Multipart responses', () => { + let originalTextDecoder: any; + beforeAll(() => { + originalTextDecoder = TextDecoder; + (globalThis as any).TextDecoder = TextDecoder; + }); + + afterAll(() => { + globalThis.TextDecoder = originalTextDecoder; + }); + + const body = [ + '---', + 'Content-Type: application/json; charset=utf-8', + 'Content-Length: 43', + '', + '{"data":{"stub":{"id":"0"}},"hasNext":true}', + '---', + 'Content-Type: application/json; charset=utf-8', + 'Content-Length: 58', + '', + '{"hasNext":false, "incremental": [{"data":{"name":"stubby"},"path":["stub"],"extensions":{"timestamp":1633038919}}]}', + '-----', + ].join("\r\n"); + + it('can handle whatwg stream bodies', (done) => { + const stream = new ReadableStream({ + async start(controller) { + const lines = body.split("\r\n"); + try { + for (const line of lines) { + await new Promise((resolve) => setTimeout(resolve, 10)); + controller.enqueue(line + "\r\n"); + } + } finally { + controller.close(); + } + }, + }); + + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + headers: new Headers({ 'content-type': 'multipart/mixed' }), + })); + + const link = new HttpLink({ + fetch: fetch as any, + }); + + let i = 0; + execute(link, { query: sampleDeferredQuery }).subscribe( + result => { + try { + if (i === 0) { + expect(result).toEqual({ + data: { + stub: { + id: "0", + }, + }, + hasNext: true, + }); + } else if (i === 1) { + expect(result).toEqual({ + incremental: [{ + data: { + name: 'stubby', + }, + extensions: { + timestamp: 1633038919, + }, + path: ['stub'], + }], + hasNext: false, + }); + } + + } catch (err) { + done(err); + } finally { + i++; + } + }, + err => { + done(err); + }, + () => { + if (i !== 2) { + done(new Error("Unexpected end to observable")); + } + + done(); + }, + ); + }); + + it('can handle node stream bodies', (done) => { + const stream = Readable.from(body.split("\r\n").map((line) => line + "\r\n")); + + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + headers: new Headers({ 'Content-Type': 'multipart/mixed;boundary="-";deferSpec=20220824' }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + + let i = 0; + execute(link, { query: sampleDeferredQuery }).subscribe( + result => { + try { + if (i === 0) { + expect(result).toEqual({ + data: { + stub: { + id: "0", + }, + }, + hasNext: true, + }); + } else if (i === 1) { + expect(result).toEqual({ + incremental: [{ + data: { + name: 'stubby', + }, + extensions: { + timestamp: 1633038919, + }, + path: ['stub'], + }], + hasNext: false, + }); + } + + } catch (err) { + done(err); + } finally { + i++; + } + }, + err => { + done(err); + }, + () => { + if (i !== 2) { + done(new Error("Unexpected end to observable")); + } + + done(); + }, + ); + }); + + itAsync('sets correct accept header on request with deferred query', (resolve, reject) => { + const stream = Readable.from(body.split("\r\n").map((line) => line + "\r\n")); + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + headers: new Headers({ 'Content-Type': 'multipart/mixed' }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + execute(link, { + query: sampleDeferredQuery + }).subscribe( + makeCallback(resolve, reject, () => { + expect(fetch).toHaveBeenCalledWith( + '/graphql', + expect.objectContaining({ + headers: { + "content-type": "application/json", + accept: "multipart/mixed; deferSpec=20220824, application/json" + } + }) + ) + }), + ); + }); + + // ensure that custom directives beginning with '@defer..' do not trigger + // custom accept header for multipart responses + itAsync('sets does not set accept header on query with custom directive begging with @defer', (resolve, reject) => { + const stream = Readable.from(body.split("\r\n").map((line) => line + "\r\n")); + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + headers: new Headers({ 'Content-Type': 'multipart/mixed' }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + execute(link, { + query: sampleQueryCustomDirective + }).subscribe( + makeCallback(resolve, reject, () => { + expect(fetch).toHaveBeenCalledWith( + '/graphql', + expect.objectContaining({ + headers: { + accept: "*/*", + "content-type": "application/json", + } + }) + ) + }), + ); + }); + }); }); diff --git a/src/link/http/__tests__/responseIterator.ts b/src/link/http/__tests__/responseIterator.ts new file mode 100644 index 00000000000..df263f51370 --- /dev/null +++ b/src/link/http/__tests__/responseIterator.ts @@ -0,0 +1,510 @@ +import gql from "graphql-tag"; +import { execute } from "../../core/execute"; +import { HttpLink } from "../HttpLink"; +import { itAsync, subscribeAndCount } from "../../../testing"; +import type { Observable } from "zen-observable-ts"; +import { ObservableQuery } from "../../../core"; +import { TextEncoder, TextDecoder } from "util"; +import { ReadableStream } from "web-streams-polyfill/ponyfill/es2018"; +import { Readable } from "stream"; + +var Blob = require('blob-polyfill').Blob; + +function makeCallback( + resolve: () => void, + reject: (error: Error) => void, + callback: (...args: TArgs) => any, +) { + return function () { + try { + callback.apply(this, arguments); + resolve(); + } catch (error) { + reject(error); + } + } as typeof callback; +} + +const sampleDeferredQuery = gql` + query SampleDeferredQuery { + stub { + id + ... on Stub @defer { + name + } + } + } +`; + +const BOUNDARY = "gc0p4Jq0M2Yt08jU534c0p"; + +function matchesResults( + resolve: () => void, + reject: (err: any) => void, + observable: Observable, + results: Array +) { + // TODO: adding a second observer to the observable will consume the + // observable. I want to test completion, but the subscribeAndCount API + // doesn’t have anything like that. + subscribeAndCount( + reject, + observable as unknown as ObservableQuery, + (count, result) => { + // subscribeAndCount is 1-indexed for some terrible reason. + if (0 >= count || count > results.length) { + reject(new Error("Unexpected result")); + } + + expect(result).toEqual(results[count - 1]); + if (count === results.length) { + resolve(); + } + } + ); +} + +describe("multipart responses", () => { + let originalTextDecoder: any; + beforeAll(() => { + originalTextDecoder = TextDecoder; + (globalThis as any).TextDecoder = TextDecoder; + }); + + afterAll(() => { + globalThis.TextDecoder = originalTextDecoder; + }); + + const bodyCustomBoundary = [ + `--${BOUNDARY}`, + "Content-Type: application/json; charset=utf-8", + "Content-Length: 43", + "", + '{"data":{"stub":{"id":"0"}},"hasNext":true}', + `--${BOUNDARY}`, + "Content-Type: application/json; charset=utf-8", + "Content-Length: 58", + "", + '{"hasNext":false, "incremental": [{"data":{"name":"stubby"},"path":["stub"]}]}', + `--${BOUNDARY}--`, + ].join("\r\n"); + + const bodyDefaultBoundary = [ + `---`, + "Content-Type: application/json; charset=utf-8", + "Content-Length: 43", + "", + '{"data":{"stub":{"id":"0"}},"hasNext":true}', + `---`, + "Content-Type: application/json; charset=utf-8", + "Content-Length: 58", + "", + '{"hasNext":false, "incremental": [{"data":{"name":"stubby"},"path":["stub"]}]}', + `-----`, + ].join("\r\n"); + + const bodyIncorrectChunkType = [ + `---`, + "Content-Type: foo/bar; charset=utf-8", + "Content-Length: 43", + "", + '{"data":{"stub":{"id":"0"}},"hasNext":true}', + `---`, + "Content-Type: foo/bar; charset=utf-8", + "Content-Length: 58", + "", + '{"hasNext":false, "incremental": [{"data":{"name":"stubby"},"path":["stub"]}]}', + `-----`, + ].join("\r\n"); + + const bodyBatchedResults = [ + "--graphql", + "content-type: application/json", + "", + '{"data":{"allProducts":[{"delivery":{"__typename":"DeliveryEstimates"},"sku":"federation","id":"apollo-federation","__typename":"Product"},{"delivery":{"__typename":"DeliveryEstimates"},"sku":"studio","id":"apollo-studio","__typename":"Product"}]},"hasNext":true}', + "--graphql", + "content-type: application/json", + "", + '{"hasNext":true,"incremental":[{"data":{"estimatedDelivery":"6/25/2021","fastestDelivery":"6/24/2021","__typename":"DeliveryEstimates"},"path":["allProducts",0,"delivery"]},{"data":{"estimatedDelivery":"6/25/2021","fastestDelivery":"6/24/2021","__typename":"DeliveryEstimates"},"path":["allProducts",1,"delivery"]}]}', + "--graphql", + "content-type: application/json", + "", + '{"hasNext":false}', + "--graphql--", + ].join("\r\n"); + + const results = [ + { + data: { + stub: { + id: "0", + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + name: "stubby", + }, + path: ["stub"], + }, + ], + hasNext: false, + }, + ]; + + const batchedResults = [ + { + data: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + hasNext: true, + }, + { + hasNext: true, + incremental: [ + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + path: ["allProducts", 0, "delivery"], + }, + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + path: ["allProducts", 1, "delivery"], + }, + ], + }, + ]; + + itAsync("can handle whatwg stream bodies", (resolve, reject) => { + const stream = new ReadableStream({ + async start(controller) { + const lines = bodyCustomBoundary.split("\r\n"); + try { + for (const line of lines) { + controller.enqueue(line + "\r\n"); + } + } finally { + controller.close(); + } + }, + }); + + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + headers: new Headers({ + "content-type": `multipart/mixed; boundary=${BOUNDARY}`, + }), + })); + + const link = new HttpLink({ + fetch: fetch as any, + }); + + const observable = execute(link, { query: sampleDeferredQuery }); + matchesResults(resolve, reject, observable, results); + }); + + itAsync( + "can handle whatwg stream bodies with arbitrary splits", + (resolve, reject) => { + const stream = new ReadableStream({ + async start(controller) { + let chunks: Array = []; + let chunkSize = 15; + for (let i = 0; i < bodyCustomBoundary.length; i += chunkSize) { + chunks.push(bodyCustomBoundary.slice(i, i + chunkSize)); + } + + try { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + } finally { + controller.close(); + } + }, + }); + + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + headers: new Headers({ + "content-type": `multipart/mixed; boundary=${BOUNDARY}`, + }), + })); + + const link = new HttpLink({ + fetch: fetch as any, + }); + + const observable = execute(link, { query: sampleDeferredQuery }); + matchesResults(resolve, reject, observable, results); + } + ); + + itAsync( + "can handle node stream bodies (strings) with default boundary", + (resolve, reject) => { + const stream = Readable.from( + bodyDefaultBoundary.split("\r\n").map((line) => line + "\r\n") + ); + + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + // if no boundary is specified, default to - + headers: new Headers({ + "content-type": `multipart/mixed`, + }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + + const observable = execute(link, { query: sampleDeferredQuery }); + matchesResults(resolve, reject, observable, results); + } + ); + + itAsync( + "can handle node stream bodies (strings) with arbitrary splits", + (resolve, reject) => { + let chunks: Array = []; + let chunkSize = 15; + for (let i = 0; i < bodyCustomBoundary.length; i += chunkSize) { + chunks.push(bodyCustomBoundary.slice(i, i + chunkSize)); + } + const stream = Readable.from(chunks); + + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + headers: new Headers({ + "content-type": `multipart/mixed; boundary=${BOUNDARY}`, + }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + + const observable = execute(link, { query: sampleDeferredQuery }); + matchesResults(resolve, reject, observable, results); + } + ); + + itAsync( + "can handle node stream bodies (array buffers)", + (resolve, reject) => { + const stream = Readable.from( + bodyDefaultBoundary + .split("\r\n") + .map((line) => new TextEncoder().encode(line + "\r\n")) + ); + + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + // if no boundary is specified, default to - + headers: new Headers({ + "content-type": `multipart/mixed`, + }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + + const observable = execute(link, { query: sampleDeferredQuery }); + matchesResults(resolve, reject, observable, results); + } + ); + + itAsync( + "can handle node stream bodies (array buffers) with batched results", + (resolve, reject) => { + const stream = Readable.from( + bodyBatchedResults + .split("\r\n") + .map((line) => new TextEncoder().encode(line + "\r\n")) + ); + + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + // if no boundary is specified, default to - + headers: new Headers({ + "content-type": `multipart/mixed;boundary="graphql";deferSpec=20220824`, + }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + + const observable = execute(link, { query: sampleDeferredQuery }); + matchesResults(resolve, reject, observable, batchedResults); + } + ); + + itAsync( + "can handle streamable blob bodies", + (resolve, reject) => { + const body = new Blob(bodyCustomBoundary.split("\r\n"), { type: "application/text" }); + const stream = new ReadableStream({ + async start(controller) { + const lines = bodyCustomBoundary.split("\r\n"); + try { + for (const line of lines) { + controller.enqueue(line + "\r\n"); + } + } finally { + controller.close(); + } + }, + }); + body.stream = () => stream; + const fetch = jest.fn(async () => ({ + status: 200, + body, + headers: new Headers({ + "content-type": `multipart/mixed; boundary=${BOUNDARY}`, + }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + + const observable = execute(link, { query: sampleDeferredQuery }); + matchesResults(resolve, reject, observable, results); + } + ); + + itAsync( + "can handle non-streamable blob bodies", + (resolve, reject) => { + const body = new Blob(bodyCustomBoundary.split("\r\n").map(i => i + "\r\n"), { type: "application/text" }); + body.stream = undefined; + + const fetch = jest.fn(async () => ({ + status: 200, + body, + headers: new Headers({ "content-type": `multipart/mixed; boundary=${BOUNDARY}` }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + + const observable = execute(link, { query: sampleDeferredQuery }); + matchesResults(resolve, reject, observable, results); + } + ); + + itAsync('throws error on non-streamable body', (resolve, reject) => { + // non-streamable body + const body = 12345; + const fetch = jest.fn(async () => ({ + status: 200, + body, + headers: new Headers({ "content-type": `multipart/mixed; boundary=${BOUNDARY}` }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + const observable = execute(link, { query: sampleDeferredQuery }); + const mockError = { throws: new Error('Unknown body type for responseIterator. Please pass a streamable response.') }; + + observable.subscribe( + () => reject('next should not have been called'), + makeCallback(resolve, reject, (error) => { + expect(error).toEqual(mockError.throws); + }), + () => reject('complete should not have been called'), + ); + }); + + // test is still failing as observer.complete is called even after error is thrown + // itAsync('throws error on unsupported patch content type', (resolve, reject) => { + // const stream = Readable.from( + // bodyIncorrectChunkType.split("\r\n").map((line) => line + "\r\n") + // ); + // const fetch = jest.fn(async () => ({ + // status: 200, + // body: stream, + // headers: new Headers({ "content-type": `multipart/mixed; boundary=${BOUNDARY}` }), + // })); + // const link = new HttpLink({ + // fetch: fetch as any, + // }); + // const observable = execute(link, { query: sampleDeferredQuery }); + // const mockError = { throws: new Error('Unsupported patch content type: application/json is required') }; + + // observable.subscribe( + // () => reject('next should not have been called'), + // makeCallback(resolve, reject, (error) => { + // expect(error).toEqual(mockError.throws); + // }), + // () => reject('complete should not have been called'), + // ); + // }); + + describe('without TextDecoder defined in the environment', () => { + beforeAll(() => { + originalTextDecoder = TextDecoder; + (globalThis as any).TextDecoder = undefined; + }); + + afterAll(() => { + globalThis.TextDecoder = originalTextDecoder; + }); + + itAsync('throws error if TextDecoder not defined in the environment', (resolve, reject) => { + const stream = Readable.from( + bodyIncorrectChunkType.split("\r\n").map((line) => line + "\r\n") + ); + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + headers: new Headers({ "content-type": `multipart/mixed; boundary=${BOUNDARY}` }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + const observable = execute(link, { query: sampleDeferredQuery }); + const mockError = { throws: new Error('TextDecoder must be defined in the environment: please import a polyfill.') }; + + observable.subscribe( + () => reject('next should not have been called'), + makeCallback(resolve, reject, (error) => { + expect(error).toEqual(mockError.throws); + }), + () => reject('complete should not have been called'), + ); + }); + }) +}); diff --git a/src/link/http/__tests__/responseIteratorNoAsyncIterator.ts b/src/link/http/__tests__/responseIteratorNoAsyncIterator.ts new file mode 100644 index 00000000000..bab9a6024d5 --- /dev/null +++ b/src/link/http/__tests__/responseIteratorNoAsyncIterator.ts @@ -0,0 +1,356 @@ +import gql from "graphql-tag"; +import { execute } from "../../core/execute"; +import { HttpLink } from "../HttpLink"; +import { itAsync, subscribeAndCount } from "../../../testing"; +import type { Observable } from "zen-observable-ts"; +import { TextEncoder, TextDecoder } from "util"; +import { ReadableStream } from "web-streams-polyfill/ponyfill/es2018"; +import { Readable } from "stream"; + +// As of Jest 26 there is no way to mock/unmock a module that is used indirectly +// via a single test file. +// These tests duplicate __tests__/responseIterator.ts while mocking +// `isAsyncIterableIterator = false` in order to test the integration of the +// implementations inside /iterators via src/link/http/responseIterator.ts +// which do not execute when isAsyncIterableIterator is true +// See: https://github.com/facebook/jest/issues/2582#issuecomment-655110424 + +jest.mock("../../../utilities/common/responseIterator", () => ({ + __esModule: true, + ...jest.requireActual("../../../utilities/common/responseIterator"), + isAsyncIterableIterator: jest.fn(() => false), +})); + +const sampleDeferredQuery = gql` + query SampleDeferredQuery { + stub { + id + ... on Stub @defer { + name + } + } + } +`; + +const BOUNDARY = "gc0p4Jq0M2Yt08jU534c0p"; + +function matchesResults( + resolve: () => void, + reject: (err: any) => void, + observable: Observable, + results: Array +) { + // TODO: adding a second observer to the observable will consume the + // observable. I want to test completion, but the subscribeAndCount API + // doesn’t have anything like that. + subscribeAndCount( + reject, + observable, + (count, result) => { + // subscribeAndCount is 1-indexed for some terrible reason. + if (0 >= count || count > results.length) { + reject(new Error("Unexpected result")); + } + + expect(result).toEqual(results[count - 1]); + if (count === results.length) { + resolve(); + } + } + ); +} + +describe("multipart responses", () => { + let originalTextDecoder: any; + beforeAll(() => { + originalTextDecoder = TextDecoder; + (globalThis as any).TextDecoder = TextDecoder; + }); + + afterAll(() => { + globalThis.TextDecoder = originalTextDecoder; + }); + + const bodyCustomBoundary = [ + `--${BOUNDARY}`, + "Content-Type: application/json; charset=utf-8", + "Content-Length: 43", + "", + '{"data":{"stub":{"id":"0"}},"hasNext":true}', + `--${BOUNDARY}`, + "Content-Type: application/json; charset=utf-8", + "Content-Length: 58", + "", + '{"hasNext":false, "incremental": [{"data":{"name":"stubby"},"path":["stub"]}]}', + `--${BOUNDARY}--`, + ].join("\r\n"); + + const bodyDefaultBoundary = [ + `---`, + "Content-Type: application/json; charset=utf-8", + "Content-Length: 43", + "", + '{"data":{"stub":{"id":"0"}},"hasNext":true}', + `---`, + "Content-Type: application/json; charset=utf-8", + "Content-Length: 58", + "", + '{"hasNext":false, "incremental": [{"data":{"name":"stubby"},"path":["stub"]}]}', + `-----`, + ].join("\r\n"); + + const bodyBatchedResults = [ + "--graphql", + "content-type: application/json", + "", + '{"data":{"allProducts":[{"delivery":{"__typename":"DeliveryEstimates"},"sku":"federation","id":"apollo-federation","__typename":"Product"},{"delivery":{"__typename":"DeliveryEstimates"},"sku":"studio","id":"apollo-studio","__typename":"Product"}]},"hasNext":true}', + "--graphql", + "content-type: application/json", + "", + '{"hasNext":true,"incremental":[{"data":{"estimatedDelivery":"6/25/2021","fastestDelivery":"6/24/2021","__typename":"DeliveryEstimates"},"path":["allProducts",0,"delivery"]},{"data":{"estimatedDelivery":"6/25/2021","fastestDelivery":"6/24/2021","__typename":"DeliveryEstimates"},"path":["allProducts",1,"delivery"]}]}', + "--graphql", + "content-type: application/json", + "", + '{"hasNext":false}', + "--graphql--", + ].join("\r\n"); + + const results = [ + { + data: { + stub: { + id: "0", + }, + }, + hasNext: true, + }, + { + incremental: [ + { + data: { + name: "stubby", + }, + path: ["stub"], + }, + ], + hasNext: false, + }, + ]; + + const batchedResults = [ + { + data: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-federation", + sku: "federation", + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + }, + id: "apollo-studio", + sku: "studio", + }, + ], + }, + hasNext: true, + }, + { + hasNext: true, + incremental: [ + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + path: ["allProducts", 0, "delivery"], + }, + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + path: ["allProducts", 1, "delivery"], + }, + ], + }, + ]; + + itAsync("can handle whatwg stream bodies", (resolve, reject) => { + const stream = new ReadableStream({ + async start(controller) { + const lines = bodyCustomBoundary.split("\r\n"); + try { + for (const line of lines) { + controller.enqueue(line + "\r\n"); + } + } finally { + controller.close(); + } + }, + }); + + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + headers: new Headers({ + "content-type": `multipart/mixed; boundary=${BOUNDARY}`, + }), + })); + + const link = new HttpLink({ + fetch: fetch as any, + }); + + const observable = execute(link, { query: sampleDeferredQuery }); + matchesResults(resolve, reject, observable, results); + }); + + itAsync( + "can handle whatwg stream bodies with arbitrary splits", + (resolve, reject) => { + const stream = new ReadableStream({ + async start(controller) { + let chunks: Array = []; + let chunkSize = 15; + for (let i = 0; i < bodyCustomBoundary.length; i += chunkSize) { + chunks.push(bodyCustomBoundary.slice(i, i + chunkSize)); + } + + try { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + } finally { + controller.close(); + } + }, + }); + + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + headers: new Headers({ + "content-type": `multipart/mixed; boundary=${BOUNDARY}`, + }), + })); + + const link = new HttpLink({ + fetch: fetch as any, + }); + + const observable = execute(link, { query: sampleDeferredQuery }); + matchesResults(resolve, reject, observable, results); + } + ); + + itAsync( + "can handle node stream bodies (strings) with default boundary", + (resolve, reject) => { + const stream = Readable.from( + bodyDefaultBoundary.split("\r\n").map((line) => line + "\r\n") + ); + + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + // if no boundary is specified, default to - + headers: new Headers({ + "content-type": `multipart/mixed`, + }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + + const observable = execute(link, { query: sampleDeferredQuery }); + matchesResults(resolve, reject, observable, results); + } + ); + + itAsync( + "can handle node stream bodies (strings) with arbitrary splits", + (resolve, reject) => { + let chunks: Array = []; + let chunkSize = 15; + for (let i = 0; i < bodyCustomBoundary.length; i += chunkSize) { + chunks.push(bodyCustomBoundary.slice(i, i + chunkSize)); + } + const stream = Readable.from(chunks); + + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + headers: new Headers({ + "content-type": `multipart/mixed; boundary=${BOUNDARY}`, + }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + + const observable = execute(link, { query: sampleDeferredQuery }); + matchesResults(resolve, reject, observable, results); + } + ); + + itAsync( + "can handle node stream bodies (array buffers)", + (resolve, reject) => { + const stream = Readable.from( + bodyDefaultBoundary + .split("\r\n") + .map((line) => new TextEncoder().encode(line + "\r\n")) + ); + + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + // if no boundary is specified, default to - + headers: new Headers({ + "content-type": `multipart/mixed`, + }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + + const observable = execute(link, { query: sampleDeferredQuery }); + matchesResults(resolve, reject, observable, results); + } + ); + + itAsync( + "can handle node stream bodies (array buffers) with batched results", + (resolve, reject) => { + const stream = Readable.from( + bodyBatchedResults + .split("\r\n") + .map((line) => new TextEncoder().encode(line + "\r\n")) + ); + + const fetch = jest.fn(async () => ({ + status: 200, + body: stream, + // if no boundary is specified, default to - + headers: new Headers({ + "Content-Type": `multipart/mixed;boundary="graphql";deferSpec=20220824`, + }), + })); + const link = new HttpLink({ + fetch: fetch as any, + }); + + const observable = execute(link, { query: sampleDeferredQuery }); + matchesResults(resolve, reject, observable, batchedResults); + } + ); +}); diff --git a/src/link/http/createHttpLink.ts b/src/link/http/createHttpLink.ts index 84aab27d809..9ed98411a09 100644 --- a/src/link/http/createHttpLink.ts +++ b/src/link/http/createHttpLink.ts @@ -3,10 +3,14 @@ import '../../utilities/globals'; import { visit, DefinitionNode, VariableDefinitionNode } from 'graphql'; import { ApolloLink } from '../core'; -import { Observable } from '../../utilities'; +import { Observable, hasDirectives } from '../../utilities'; import { serializeFetchParameter } from './serializeFetchParameter'; import { selectURI } from './selectURI'; -import { parseAndCheckHttpResponse } from './parseAndCheckHttpResponse'; +import { + handleError, + readMultipartBody, + readJsonBody +} from './parseAndCheckHttpResponse'; import { checkFetcher } from './checkFetcher'; import { selectHttpOptionsAndBodyInternal, @@ -131,6 +135,11 @@ export const createHttpLink = (linkOptions: HttpOptions = {}) => { options.method = 'GET'; } + // does not match custom directives beginning with @defer + if (hasDirectives(['defer'], operation.query)) { + options.headers.accept = "multipart/mixed; deferSpec=20220824, application/json"; + } + if (options.method === 'GET') { const { newURI, parseError } = rewriteURIForGET(chosenURI, body); if (parseError) { @@ -156,55 +165,15 @@ export const createHttpLink = (linkOptions: HttpOptions = {}) => { currentFetch!(chosenURI, options) .then(response => { operation.setContext({ response }); - return response; - }) - .then(parseAndCheckHttpResponse(operation)) - .then(result => { - // we have data and can send it to back up the link chain - observer.next(result); - observer.complete(); - return result; - }) - .catch(err => { - // fetch was cancelled so it's already been cleaned up in the unsubscribe - if (err.name === 'AbortError') return; - // if it is a network error, BUT there is graphql result info - // fire the next observer before calling error - // this gives apollo-client (and react-apollo) the `graphqlErrors` and `networErrors` - // to pass to UI - // this should only happen if we *also* have data as part of the response key per - // the spec - if (err.result && err.result.errors && err.result.data) { - // if we don't call next, the UI can only show networkError because AC didn't - // get any graphqlErrors - // this is graphql execution result info (i.e errors and possibly data) - // this is because there is no formal spec how errors should translate to - // http status codes. So an auth error (401) could have both data - // from a public field, errors from a private field, and a status of 401 - // { - // user { // this will have errors - // firstName - // } - // products { // this is public so will have data - // cost - // } - // } - // - // the result of above *could* look like this: - // { - // data: { products: [{ cost: "$10" }] }, - // errors: [{ - // message: 'your session has timed out', - // path: [] - // }] - // } - // status code of above would be a 401 - // in the UI you want to show data where you can, errors as data where you can - // and use correct http status codes - observer.next(err.result); + const ctype = response.headers?.get('content-type'); + + if (ctype !== null && /^multipart\/mixed/i.test(ctype)) { + return readMultipartBody(response, observer); + } else { + return readJsonBody(response, operation, observer); } - observer.error(err); - }); + }) + .catch(err => handleError(err, observer)); return () => { // XXX support canceling this request diff --git a/src/link/http/iterators/LICENSE b/src/link/http/iterators/LICENSE new file mode 100644 index 00000000000..5d5774fdb5e --- /dev/null +++ b/src/link/http/iterators/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-2022 Kevin Malakoff + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/link/http/iterators/async.ts b/src/link/http/iterators/async.ts new file mode 100644 index 00000000000..ce26e925710 --- /dev/null +++ b/src/link/http/iterators/async.ts @@ -0,0 +1,18 @@ +/** + * Original source: + * https://github.com/kmalakoff/response-iterator/blob/master/src/iterators/async.ts + */ + +export default function asyncIterator( + source: AsyncIterableIterator +): AsyncIterableIterator { + const iterator = source[Symbol.asyncIterator](); + return { + next(): Promise> { + return iterator.next(); + }, + [Symbol.asyncIterator](): AsyncIterableIterator { + return this; + }, + }; +} diff --git a/src/link/http/iterators/nodeStream.ts b/src/link/http/iterators/nodeStream.ts new file mode 100644 index 00000000000..c0e00664c4e --- /dev/null +++ b/src/link/http/iterators/nodeStream.ts @@ -0,0 +1,94 @@ +/** + * Original source: + * https://github.com/kmalakoff/response-iterator/blob/master/src/iterators/nodeStream.ts + */ + +import { Readable as NodeReadableStream } from "stream"; +import { canUseAsyncIteratorSymbol } from "../../../utilities"; + +interface NodeStreamIterator { + next(): Promise>; + [Symbol.asyncIterator]?(): AsyncIterator; +} + +export default function nodeStreamIterator( + stream: NodeReadableStream +): AsyncIterableIterator { + let cleanup: (() => void) | null = null; + let error: Error | null = null; + let done = false; + const data: unknown[] = []; + + const waiting: [ + ( + value: + | IteratorResult + | PromiseLike> + ) => void, + (reason?: any) => void + ][] = []; + + function onData(chunk: any) { + if (error) return; + if (waiting.length) { + const shiftedArr = waiting.shift(); + if (Array.isArray(shiftedArr) && shiftedArr[0]) { + return shiftedArr[0]({ value: chunk, done: false }); + } + } + data.push(chunk); + } + function onError(err: Error) { + error = err; + const all = waiting.slice(); + all.forEach(function (pair) { + pair[1](err); + }); + !cleanup || cleanup(); + } + function onEnd() { + done = true; + const all = waiting.slice(); + all.forEach(function (pair) { + pair[0]({ value: undefined, done: true }); + }); + !cleanup || cleanup(); + } + + cleanup = function () { + cleanup = null; + stream.removeListener("data", onData); + stream.removeListener("error", onError); + stream.removeListener("end", onEnd); + stream.removeListener("finish", onEnd); + stream.removeListener("close", onEnd); + }; + stream.on("data", onData); + stream.on("error", onError); + stream.on("end", onEnd); + stream.on("finish", onEnd); + stream.on("close", onEnd); + + function getNext(): Promise> { + return new Promise(function (resolve, reject) { + if (error) return reject(error); + if (data.length) return resolve({ value: data.shift() as T, done: false }); + if (done) return resolve({ value: undefined, done: true }); + waiting.push([resolve, reject]); + }); + } + + const iterator: NodeStreamIterator = { + next(): Promise> { + return getNext(); + }, + }; + + if (canUseAsyncIteratorSymbol) { + iterator[Symbol.asyncIterator] = function (): AsyncIterator { + return this; + }; + } + + return iterator as AsyncIterableIterator; +} diff --git a/src/link/http/iterators/promise.ts b/src/link/http/iterators/promise.ts new file mode 100644 index 00000000000..10668c547a8 --- /dev/null +++ b/src/link/http/iterators/promise.ts @@ -0,0 +1,43 @@ +/** + * Original source: + * https://github.com/kmalakoff/response-iterator/blob/master/src/iterators/promise.ts + */ + +import { canUseAsyncIteratorSymbol } from "../../../utilities"; + +interface PromiseIterator { + next(): Promise>; + [Symbol.asyncIterator]?(): AsyncIterator; +} + +export default function promiseIterator( + promise: Promise +): AsyncIterableIterator { + let resolved = false; + + const iterator: PromiseIterator = { + next(): Promise> { + if (resolved) + return Promise.resolve({ + value: undefined, + done: true, + }); + resolved = true; + return new Promise(function (resolve, reject) { + promise + .then(function (value) { + resolve({ value: value as unknown as T, done: false }); + }) + .catch(reject); + }); + }, + }; + + if (canUseAsyncIteratorSymbol) { + iterator[Symbol.asyncIterator] = function (): AsyncIterator { + return this; + }; + } + + return iterator as AsyncIterableIterator; +} diff --git a/src/link/http/iterators/reader.ts b/src/link/http/iterators/reader.ts new file mode 100644 index 00000000000..4fd684f9d71 --- /dev/null +++ b/src/link/http/iterators/reader.ts @@ -0,0 +1,29 @@ +/** + * Original source: + * https://github.com/kmalakoff/response-iterator/blob/master/src/iterators/reader.ts + */ + +import { canUseAsyncIteratorSymbol } from "../../../utilities"; + +interface ReaderIterator { + next(): Promise>; + [Symbol.asyncIterator]?(): AsyncIterator; +} + +export default function readerIterator( + reader: ReadableStreamDefaultReader +): AsyncIterableIterator { + const iterator: ReaderIterator = { + next() { + return reader.read(); + }, + }; + + if (canUseAsyncIteratorSymbol) { + iterator[Symbol.asyncIterator] = function (): AsyncIterator { + return this; + }; + } + + return iterator as AsyncIterableIterator; +} diff --git a/src/link/http/parseAndCheckHttpResponse.ts b/src/link/http/parseAndCheckHttpResponse.ts index 2f314fd0a5b..7d6b98b888b 100644 --- a/src/link/http/parseAndCheckHttpResponse.ts +++ b/src/link/http/parseAndCheckHttpResponse.ts @@ -1,5 +1,7 @@ -import { Operation } from '../core'; -import { throwServerError } from '../utils'; +import { responseIterator } from "./responseIterator"; +import { Operation } from "../core"; +import { throwServerError } from "../utils"; +import { Observer } from "../../utilities"; const { hasOwnProperty } = Object.prototype; @@ -9,49 +11,194 @@ export type ServerParseError = Error & { bodyText: string; }; -export function parseAndCheckHttpResponse( - operations: Operation | Operation[], +export async function readMultipartBody>( + response: Response, + observer: Observer ) { - return (response: Response) => response - .text() - .then(bodyText => { - try { - return JSON.parse(bodyText); - } catch (err) { - const parseError = err as ServerParseError; - parseError.name = 'ServerParseError'; - parseError.response = response; - parseError.statusCode = response.status; - parseError.bodyText = bodyText; - throw parseError; + if (TextDecoder === undefined) { + throw new Error( + "TextDecoder must be defined in the environment: please import a polyfill." + ); + } + const decoder = new TextDecoder("utf-8"); + const contentType = response.headers?.get('content-type'); + const delimiter = "boundary="; + + // parse boundary value and ignore any subsequent name/value pairs after ; + // https://www.rfc-editor.org/rfc/rfc9110.html#name-parameters + // e.g. multipart/mixed;boundary="graphql";deferSpec=20220824 + // if no boundary is specified, default to - + const boundaryVal = contentType?.includes(delimiter) + ? contentType + ?.substring(contentType?.indexOf(delimiter) + delimiter.length) + .replace(/['"]/g, "") + .replace(/\;(.*)/gm, "") + .trim() + : "-"; + + let boundary = `--${boundaryVal}`; + let buffer = ""; + const iterator = responseIterator(response); + let running = true; + + while (running) { + const { value, done } = await iterator.next(); + const chunk = typeof value === "string" ? value : decoder.decode(value); + running = !done; + buffer += chunk; + let bi = buffer.indexOf(boundary); + + while (bi > -1) { + let message: string; + [message, buffer] = [ + buffer.slice(0, bi), + buffer.slice(bi + boundary.length), + ]; + if (message.trim()) { + const i = message.indexOf("\r\n\r\n"); + const headers = parseHeaders(message.slice(0, i)); + const contentType = headers["content-type"]; + if ( + contentType && + contentType.toLowerCase().indexOf("application/json") === -1 + ) { + throw new Error("Unsupported patch content type: application/json is required."); + } + const body = message.slice(i); + + try { + const result = parseJsonBody(response, body.replace("\r\n", "")); + if ( + Object.keys(result).length > 1 || + "data" in result || + "incremental" in result || + "errors" in result + ) { + // for the last chunk with only `hasNext: false`, + // we don't need to call observer.next as there is no data/errors + observer.next?.(result); + } + } catch (err) { + handleError(err, observer); + } } + bi = buffer.indexOf(boundary); + } + } + observer.complete?.(); +} + +export function parseHeaders(headerText: string): Record { + const headersInit: Record = {}; + headerText.split("\n").forEach((line) => { + const i = line.indexOf(":"); + if (i > -1) { + // normalize headers to lowercase + const name = line.slice(0, i).trim().toLowerCase(); + const value = line.slice(i + 1).trim(); + headersInit[name] = value; + } + }); + return headersInit; +} + +export function parseJsonBody(response: Response, bodyText: string): T { + try { + return JSON.parse(bodyText) as T; + } catch (err) { + const parseError = err as ServerParseError; + parseError.name = "ServerParseError"; + parseError.response = response; + parseError.statusCode = response.status; + parseError.bodyText = bodyText; + throw parseError; + } +} + +export function handleError(err: any, observer: Observer) { + if (err.name === "AbortError") return; + // if it is a network error, BUT there is graphql result info fire + // the next observer before calling error this gives apollo-client + // (and react-apollo) the `graphqlErrors` and `networErrors` to + // pass to UI this should only happen if we *also* have data as + // part of the response key per the spec + if (err.result && err.result.errors && err.result.data) { + // if we don't call next, the UI can only show networkError + // because AC didn't get any graphqlErrors this is graphql + // execution result info (i.e errors and possibly data) this is + // because there is no formal spec how errors should translate to + // http status codes. So an auth error (401) could have both data + // from a public field, errors from a private field, and a status + // of 401 + // { + // user { // this will have errors + // firstName + // } + // products { // this is public so will have data + // cost + // } + // } + // + // the result of above *could* look like this: + // { + // data: { products: [{ cost: "$10" }] }, + // errors: [{ + // message: 'your session has timed out', + // path: [] + // }] + // } + // status code of above would be a 401 + // in the UI you want to show data where you can, errors as data where you can + // and use correct http status codes + observer.next?.(err.result); + } + + observer.error?.(err); +} + +export function readJsonBody>( + response: Response, + operation: Operation, + observer: Observer +) { + parseAndCheckHttpResponse(operation)(response) + .then((result) => { + observer.next?.(result); + observer.complete?.(); }) - .then((result: any) => { - if (response.status >= 300) { - // Network error - throwServerError( - response, - result, - `Response not successful: Received status code ${response.status}`, - ); - } + .catch((err) => handleError(err, observer)); +} - if ( - !Array.isArray(result) && - !hasOwnProperty.call(result, 'data') && - !hasOwnProperty.call(result, 'errors') - ) { - // Data error - throwServerError( - response, - result, - `Server response was missing for query '${ - Array.isArray(operations) - ? operations.map(op => op.operationName) - : operations.operationName - }'.`, - ); - } - return result; - }); +export function parseAndCheckHttpResponse(operations: Operation | Operation[]) { + return (response: Response) => + response + .text() + .then((bodyText) => parseJsonBody(response, bodyText)) + .then((result: any) => { + if (response.status >= 300) { + // Network error + throwServerError( + response, + result, + `Response not successful: Received status code ${response.status}` + ); + } + if ( + !Array.isArray(result) && + !hasOwnProperty.call(result, "data") && + !hasOwnProperty.call(result, "errors") + ) { + // Data error + throwServerError( + response, + result, + `Server response was missing for query '${ + Array.isArray(operations) + ? operations.map((op) => op.operationName) + : operations.operationName + }'.` + ); + } + return result; + }); } diff --git a/src/link/http/responseIterator.ts b/src/link/http/responseIterator.ts new file mode 100644 index 00000000000..a0e565e464c --- /dev/null +++ b/src/link/http/responseIterator.ts @@ -0,0 +1,47 @@ +/** + * Original source: + * https://github.com/kmalakoff/response-iterator/blob/master/src/index.ts + */ + +import { Response as NodeResponse } from "node-fetch"; +import { + isAsyncIterableIterator, + isBlob, + isNodeResponse, + isNodeReadableStream, + isReadableStream, + isStreamableBlob, +} from "../../utilities/common/responseIterator"; + +import asyncIterator from "./iterators/async"; +import nodeStreamIterator from "./iterators/nodeStream"; +import promiseIterator from "./iterators/promise"; +import readerIterator from "./iterators/reader"; + +export function responseIterator( + response: Response | NodeResponse +): AsyncIterableIterator { + let body: unknown = response; + + if (isNodeResponse(response)) body = response.body; + + if (isAsyncIterableIterator(body)) return asyncIterator(body); + + if (isReadableStream(body)) return readerIterator(body.getReader()); + + // this errors without casting to ReadableStream + // because Blob.stream() returns a NodeJS ReadableStream + if (isStreamableBlob(body)) { + return readerIterator( + (body.stream() as unknown as ReadableStream).getReader() + ); + } + + if (isBlob(body)) return promiseIterator(body.arrayBuffer()); + + if (isNodeReadableStream(body)) return nodeStreamIterator(body); + + throw new Error( + "Unknown body type for responseIterator. Please pass a streamable response." + ); +} diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 0a7219d1fbd..a0704888388 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -17,7 +17,13 @@ import { InMemoryCache } from '../../../cache'; import { ApolloProvider } from '../../context'; import { Observable, Reference, concatPagination } from '../../../utilities'; import { ApolloLink } from '../../../link/core'; -import { itAsync, MockLink, MockedProvider, mockSingleLink } from '../../../testing'; +import { + itAsync, + MockLink, + MockedProvider, + MockSubscriptionLink, + mockSingleLink, +} from '../../../testing'; import { QueryResult } from "../../types/types"; import { useQuery } from '../useQuery'; import { useMutation } from '../useMutation'; @@ -4912,4 +4918,728 @@ describe('useQuery Hook', () => { }).then(resolve, reject); }); }); + + describe('defer', () => { + it('should handle deferred queries', async () => { + const query = gql` + { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const link = new MockSubscriptionLink(); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + + const { result, waitForNextUpdate } = renderHook( + () => useQuery(query), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + setTimeout(() => { + link.simulateResult({ + result: { + data: { + greeting: { + message: 'Hello world', + __typename: 'Greeting', + }, + }, + hasNext: true + }, + }); + }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ + greeting: { + message: 'Hello world', + __typename: 'Greeting', + }, + }); + + setTimeout(() => { + link.simulateResult({ + result: { + incremental: [{ + data: { + recipient: { + name: 'Alice', + __typename: 'Person', + }, + __typename: 'Greeting', + }, + path: ['greeting'], + }], + hasNext: false + }, + }); + }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ + greeting: { + message: 'Hello world', + __typename: 'Greeting', + recipient: { + name: 'Alice', + __typename: 'Person', + }, + }, + }); + }); + + it('should handle deferred queries in lists', async () => { + const query = gql` + { + greetings { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const link = new MockSubscriptionLink(); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + + const { result, waitForNextUpdate } = renderHook( + () => useQuery(query), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + setTimeout(() => { + link.simulateResult({ + result: { + data: { + greetings: [ + { message: 'Hello world', __typename: 'Greeting' }, + { message: 'Hello again', __typename: 'Greeting' }, + ], + }, + hasNext: true + }, + }); + }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ + greetings: [ + { message: 'Hello world', __typename: 'Greeting' }, + { message: 'Hello again', __typename: 'Greeting' }, + ], + }); + + setTimeout(() => { + link.simulateResult({ + result: { + incremental: [{ + data: { + recipient: { + name: 'Alice', + __typename: 'Person', + }, + __typename: 'Greeting', + }, + path: ['greetings', 0], + }], + hasNext: true, + }, + }); + }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ + greetings: [ + { + message: 'Hello world', + __typename: 'Greeting', + recipient: { name: 'Alice', __typename: 'Person' }, + }, + { message: 'Hello again', __typename: 'Greeting' }, + ], + }); + + setTimeout(() => { + link.simulateResult({ + result: { + incremental: [{ + data: { + recipient: { + name: 'Bob', + __typename: 'Person', + }, + __typename: 'Greeting', + }, + path: ['greetings', 1], + }], + hasNext: false + }, + }); + }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ + greetings: [ + { + message: 'Hello world', + __typename: 'Greeting', + recipient: { name: 'Alice', __typename: 'Person' }, + }, + { + message: 'Hello again', + __typename: 'Greeting', + recipient: { name: 'Bob', __typename: 'Person' }, + }, + ], + }); + }); + + it('should handle deferred queries in lists, merging arrays', async () => { + const query = gql` + query DeferVariation { + allProducts { + delivery { + ...MyFragment @defer + } + sku, + id + } + } + fragment MyFragment on DeliveryEstimates { + estimatedDelivery + fastestDelivery + } + `; + + const link = new MockSubscriptionLink(); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + + const { result, waitForNextUpdate } = renderHook( + () => useQuery(query), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + setTimeout(() => { + link.simulateResult({ + result: { + data: { + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates" + }, + id: "apollo-federation", + sku: "federation" + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates" + }, + id: "apollo-studio", + sku: "studio" + } + ] + }, + hasNext: true + }, + }); + }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates" + }, + id: "apollo-federation", + sku: "federation" + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates" + }, + id: "apollo-studio", + sku: "studio" + } + ] + }); + + setTimeout(() => { + link.simulateResult({ + result: { + hasNext: true, + incremental: [ + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + path: [ + "allProducts", + 0, + "delivery" + ] + }, + { + data: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021", + }, + path: [ + "allProducts", + 1, + "delivery" + ] + }, + ] + }, + }); + }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ + allProducts: [ + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021" + }, + id: "apollo-federation", + sku: "federation" + }, + { + __typename: "Product", + delivery: { + __typename: "DeliveryEstimates", + estimatedDelivery: "6/25/2021", + fastestDelivery: "6/24/2021" + }, + id: "apollo-studio", + sku: "studio" + } + ] + }); + }); + + it('should handle deferred queries with fetch policy no-cache', async () => { + const query = gql` + { + greeting { + message + ... on Greeting @defer { + recipient { + name + } + } + } + } + `; + + const link = new MockSubscriptionLink(); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + + const { result, waitForNextUpdate } = renderHook( + () => useQuery(query, {fetchPolicy: 'no-cache'}), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + setTimeout(() => { + link.simulateResult({ + result: { + data: { + greeting: { + message: 'Hello world', + __typename: 'Greeting', + }, + }, + hasNext: true + }, + }); + }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ + greeting: { + message: 'Hello world', + __typename: 'Greeting', + }, + }); + + setTimeout(() => { + link.simulateResult({ + result: { + incremental: [{ + data: { + recipient: { + name: 'Alice', + __typename: 'Person', + }, + __typename: 'Greeting', + }, + path: ['greeting'], + }], + hasNext: false + }, + }); + }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ + greeting: { + message: 'Hello world', + __typename: 'Greeting', + recipient: { + name: 'Alice', + __typename: 'Person', + }, + }, + }); + }); + + it('should handle deferred queries with errors returned on the incremental batched result', async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const link = new MockSubscriptionLink(); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + + const { result, waitForNextUpdate } = renderHook( + () => useQuery(query), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + setTimeout(() => { + link.simulateResult({ + result: { + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker" + }, + { + id: "1003", + name: "Leia Organa" + } + ] + } + }, + hasNext: true + }, + }); + }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ + hero: { + heroFriends: [ + { + id: '1000', + name: 'Luke Skywalker' + }, + { + id: '1003', + name: 'Leia Organa' + }, + ], + name: "R2-D2" + } + }); + + setTimeout(() => { + link.simulateResult({ + result: { + incremental: [ + { + path: ["hero", "heroFriends", 0], + errors: [ + new GraphQLError( + "homeWorld for character with ID 1000 could not be fetched.", + { path: ["hero", "heroFriends", 0, "homeWorld"] } + ) + ], + data: { + "homeWorld": null, + } + }, + { + path: ["hero", "heroFriends", 1], + data: { + "homeWorld": "Alderaan", + } + }, + ], + "hasNext": false + } + }); + }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeInstanceOf(ApolloError); + expect(result.current.error!.message).toBe('homeWorld for character with ID 1000 could not be fetched.'); + + // since default error policy is "none", we do *not* return partial results + expect(result.current.data).toEqual({ + hero: { + heroFriends: [ + { + id: '1000', + name: 'Luke Skywalker' + }, + { + id: '1003', + name: 'Leia Organa' + }, + ], + name: "R2-D2" + } + }); + }); + + it('should handle deferred queries with errors returned on the incremental batched result and errorPolicy "all"', async () => { + const query = gql` + query { + hero { + name + heroFriends { + id + name + ... @defer { + homeWorld + } + } + } + } + `; + + const link = new MockSubscriptionLink(); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache(), + }); + + const { result, waitForNextUpdate } = renderHook( + () => useQuery(query, { errorPolicy: "all" }), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(result.current.loading).toBe(true); + expect(result.current.data).toBe(undefined); + setTimeout(() => { + link.simulateResult({ + result: { + data: { + hero: { + name: "R2-D2", + heroFriends: [ + { + id: "1000", + name: "Luke Skywalker" + }, + { + id: "1003", + name: "Leia Organa" + } + ] + } + }, + hasNext: true + }, + }); + }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + expect(result.current.data).toEqual({ + hero: { + heroFriends: [ + { + id: '1000', + name: 'Luke Skywalker' + }, + { + id: '1003', + name: 'Leia Organa' + }, + ], + name: "R2-D2" + } + }); + + setTimeout(() => { + link.simulateResult({ + result: { + extensions: { + thing1: 'foo', + thing2: 'bar', + }, + incremental: [ + { + path: ["hero", "heroFriends", 0], + errors: [ + new GraphQLError( + "homeWorld for character with ID 1000 could not be fetched.", + { path: ["hero", "heroFriends", 0, "homeWorld"] } + ) + ], + data: { + "homeWorld": null, + } + }, + { + path: ["hero", "heroFriends", 1], + data: { + "homeWorld": "Alderaan", + } + }, + ], + "hasNext": false + } + }); + }); + + await waitForNextUpdate(); + expect(result.current.loading).toBe(false); + // @ts-ignore + expect(result.current.label).toBe(undefined); + // @ts-ignore + expect(result.current.extensions).toBe(undefined); + expect(result.current.error).toBeInstanceOf(ApolloError); + expect(result.current.error!.message).toBe('homeWorld for character with ID 1000 could not be fetched.'); + + // since default error policy is "all", we *do* return partial results + expect(result.current.data).toEqual({ + hero: { + heroFriends: [ + { + // the only difference with the previous test + // is that homeWorld is populated since errorPolicy: all + // populates both partial data and error.graphQLErrors + homeWorld: null, + id: '1000', + name: 'Luke Skywalker' + }, + { + // homeWorld is populated due to errorPolicy: all + homeWorld: "Alderaan", + id: '1003', + name: 'Leia Organa' + }, + ], + name: "R2-D2" + } + }); + }); + }); }); diff --git a/src/testing/core/subscribeAndCount.ts b/src/testing/core/subscribeAndCount.ts index eb1488015c6..a80f4ebe94c 100644 --- a/src/testing/core/subscribeAndCount.ts +++ b/src/testing/core/subscribeAndCount.ts @@ -1,28 +1,21 @@ -import { ObservableQuery, ApolloQueryResult, OperationVariables } from '../../core'; -import { ObservableSubscription, asyncMap } from '../../utilities'; +import { ObservableSubscription, asyncMap, Observable } from '../../utilities'; -export default function subscribeAndCount< - TData, - TVariables = OperationVariables, ->( +export default function subscribeAndCount( reject: (reason: any) => any, - observable: ObservableQuery, - cb: (handleCount: number, result: ApolloQueryResult) => any, + observable: Observable, + cb: (handleCount: number, result: TResult) => any, ): ObservableSubscription { // Use a Promise queue to prevent callbacks from being run out of order. let queue = Promise.resolve(); let handleCount = 0; - const subscription = asyncMap( - observable, - (result: ApolloQueryResult) => { - // All previous asynchronous callbacks must complete before cb can - // be invoked with this result. - return queue = queue.then(() => { - return cb(++handleCount, result); - }).catch(error); - }, - ).subscribe({ error }); + const subscription = asyncMap(observable, result => { + // All previous asynchronous callbacks must complete before cb can + // be invoked with this result. + return queue = queue.then(() => { + return cb(++handleCount, result); + }).catch(error); + }).subscribe({ error }); function error(e: any) { subscription.unsubscribe(); diff --git a/src/utilities/common/canUse.ts b/src/utilities/common/canUse.ts index 0453fbac425..453379b7ba7 100644 --- a/src/utilities/common/canUse.ts +++ b/src/utilities/common/canUse.ts @@ -10,6 +10,8 @@ export const canUseSymbol = typeof Symbol === 'function' && typeof Symbol.for === 'function'; +export const canUseAsyncIteratorSymbol = canUseSymbol && Symbol.asyncIterator; + export const canUseDOM = typeof maybe(() => window.document.createElement) === "function"; diff --git a/src/utilities/common/incrementalResult.ts b/src/utilities/common/incrementalResult.ts new file mode 100644 index 00000000000..ddb6be317cc --- /dev/null +++ b/src/utilities/common/incrementalResult.ts @@ -0,0 +1,5 @@ +import { ExecutionPatchIncrementalResult } from '../../link/core'; + +export function isExecutionPatchIncrementalResult(value: any): value is ExecutionPatchIncrementalResult { + return !!(value as ExecutionPatchIncrementalResult).incremental; +} diff --git a/src/utilities/common/responseIterator.ts b/src/utilities/common/responseIterator.ts new file mode 100644 index 00000000000..99a02096b94 --- /dev/null +++ b/src/utilities/common/responseIterator.ts @@ -0,0 +1,32 @@ +import { Response as NodeResponse } from "node-fetch"; +import { Readable as NodeReadableStream } from "stream"; +import { canUseAsyncIteratorSymbol } from "./canUse"; + +export function isNodeResponse(value: any): value is NodeResponse { + return !!(value as NodeResponse).body; +} + +export function isReadableStream(value: any): value is ReadableStream { + return !!(value as ReadableStream).getReader; +} + +export function isAsyncIterableIterator( + value: any +): value is AsyncIterableIterator { + return !!( + canUseAsyncIteratorSymbol && + (value as AsyncIterableIterator)[Symbol.asyncIterator] + ); +} + +export function isStreamableBlob(value: any): value is Blob { + return !!(value as Blob).stream; +} + +export function isBlob(value: any): value is Blob { + return !!(value as Blob).arrayBuffer; +} + +export function isNodeReadableStream(value: any): value is NodeReadableStream { + return !!(value as NodeReadableStream).pipe; +} diff --git a/src/utilities/graphql/directives.ts b/src/utilities/graphql/directives.ts index 76c36b08083..4e335d261a7 100644 --- a/src/utilities/graphql/directives.ts +++ b/src/utilities/graphql/directives.ts @@ -12,6 +12,7 @@ import { ValueNode, ASTNode, visit, + BREAK, } from 'graphql'; export type DirectiveInfo = { @@ -55,9 +56,21 @@ export function getDirectiveNames(root: ASTNode) { } export function hasDirectives(names: string[], root: ASTNode) { - return getDirectiveNames(root).some( - (name: string) => names.indexOf(name) > -1, - ); + const nameSet = new Set(names); + const nameCount = nameSet.size; + + visit(root, { + Directive(node) { + if (nameSet.delete(node.name.value)) { + // We can abandon the traversal early as soon as we encounter any of the + // directives in the names array, since hasDirectives returns true when + // any (not necessarily all) of the named directives are found. + return BREAK; + } + }, + }); + + return nameSet.size < nameCount; } export function hasClientExports(document: DocumentNode) {