diff --git a/.changeset/plenty-squids-tie.md b/.changeset/plenty-squids-tie.md new file mode 100644 index 00000000000..39922e3ad11 --- /dev/null +++ b/.changeset/plenty-squids-tie.md @@ -0,0 +1,6 @@ +--- +'@graphql-tools/url-loader': patch +'@graphql-tools/utils': patch +--- + +Fix @stream support diff --git a/packages/loaders/url/src/handleMultipartMixedResponse.ts b/packages/loaders/url/src/handleMultipartMixedResponse.ts index af29a829b3d..103652101bd 100644 --- a/packages/loaders/url/src/handleMultipartMixedResponse.ts +++ b/packages/loaders/url/src/handleMultipartMixedResponse.ts @@ -39,9 +39,17 @@ export async function handleMultipartMixedResponse(response: Response, controlle const executionResult: ExecutionResult = {}; function handleResult(result: ExecutionResult) { - if (result.path && result.data) { + if (result.path) { + const path = ['data', ...result.path]; executionResult.data = executionResult.data || {}; - dset(executionResult, ['data', ...result.path], result.data); + if (result.items) { + for (const item of result.items) { + dset(executionResult, path, item); + } + } + if (result.data) { + dset(executionResult, ['data', ...result.path], result.data); + } } else if (result.data) { executionResult.data = executionResult.data || {}; Object.assign(executionResult.data, result.data); diff --git a/packages/loaders/url/tests/url-loader-browser.spec.ts b/packages/loaders/url/tests/url-loader-browser.spec.ts index 6407b3c9ccb..a46bcb949fe 100644 --- a/packages/loaders/url/tests/url-loader-browser.spec.ts +++ b/packages/loaders/url/tests/url-loader-browser.spec.ts @@ -22,6 +22,7 @@ describe('[url-loader] webpack bundle compat', () => { typeDefs: /* GraphQL */ ` type Query { foo: Boolean + countdown(from: Int): [Int] } type Subscription { foo: Boolean @@ -30,6 +31,12 @@ describe('[url-loader] webpack bundle compat', () => { resolvers: { Query: { foo: () => new Promise(resolve => setTimeout(() => resolve(true), 300)), + countdown: async function* (_, { from }) { + for (let i = from; i >= 0; i--) { + yield i; + await new Promise(resolve => setTimeout(resolve, 100)); + } + }, }, Subscription: { foo: { @@ -175,7 +182,7 @@ describe('[url-loader] webpack bundle compat', () => { expect(result).toStrictEqual(expectedData); }); - it('handles executing a operation using multipart responses', async () => { + it('handles executing a @defer operation using multipart responses', async () => { const document = parse(/* GraphQL */ ` query { ... on Query @defer { @@ -206,6 +213,43 @@ describe('[url-loader] webpack bundle compat', () => { expect(results).toEqual([{ data: {} }, { data: { foo: true } }]); }); + it('handles executing a @stream operation using multipart responses', async () => { + const document = parse(/* GraphQL */ ` + query { + countdown(from: 3) @stream + } + `); + + const results = await page.evaluate( + async (httpAddress, document) => { + const module = window['GraphQLToolsUrlLoader'] as typeof UrlLoaderModule; + const loader = new module.UrlLoader(); + const executor = loader.getExecutorAsync(httpAddress + '/graphql'); + const result = await executor({ + document, + }); + const results = []; + for await (const currentResult of result as any[]) { + if (currentResult) { + results.push(JSON.parse(JSON.stringify(currentResult))); + } + } + return results; + }, + httpAddress, + document as any + ); + + expect(results).toEqual([ + { data: { countdown: [] } }, + { data: { countdown: [3] } }, + { data: { countdown: [3, 2] } }, + { data: { countdown: [3, 2, 1] } }, + { data: { countdown: [3, 2, 1, 0] } }, + { data: { countdown: [3, 2, 1, 0] } }, + ]); + }); + it('handles SSE subscription operations', async () => { const expectedDatas = [{ data: { foo: true } }, { data: { foo: false } }]; diff --git a/packages/loaders/url/tests/yoga-compat.spec.ts b/packages/loaders/url/tests/yoga-compat.spec.ts index e8ec732a198..d72c1b3c15b 100644 --- a/packages/loaders/url/tests/yoga-compat.spec.ts +++ b/packages/loaders/url/tests/yoga-compat.spec.ts @@ -18,7 +18,34 @@ describe('Yoga Compatibility', () => { let serverPath: string; let active = false; let cnt = 0; - + const alphabet = [ + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'l', + 'm', + 'n', + 'o', + 'p', + 'q', + 'r', + 's', + 't', + 'u', + 'v', + 'w', + 'x', + 'y', + 'z', + ]; beforeAll(async () => { const yoga = createYoga({ schema: createSchema({ @@ -27,6 +54,11 @@ describe('Yoga Compatibility', () => { type Query { foo: Foo cnt: Int + """ + Resolves the alphabet slowly. 1 character per second + Maybe you want to @stream this field ;) + """ + alphabet(waitFor: Int! = 1000): [String] } type Foo { a: Int @@ -40,10 +72,22 @@ describe('Yoga Compatibility', () => { Query: { foo: () => ({}), cnt: () => cnt, + async *alphabet(_, { waitFor }) { + for (const character of alphabet) { + yield character; + await sleep(waitFor); + } + }, }, Foo: { - a: () => new Promise(resolve => setTimeout(() => resolve(1), 300)), - b: () => new Promise(resolve => setTimeout(() => resolve(2), 600)), + a: async () => { + await sleep(300); + return 1; + }, + b: async () => { + await sleep(600); + return 2; + }, }, Subscription: { foo: { @@ -91,7 +135,7 @@ describe('Yoga Compatibility', () => { } }); - it('should handle multipart response result', async () => { + it('should handle defer', async () => { expect.assertions(5); const expectedDatas: ExecutionResult[] = [ { @@ -138,6 +182,35 @@ describe('Yoga Compatibility', () => { expect(expectedDatas.length).toBe(0); }); + it('should handle stream', async () => { + const document = parse(/* GraphQL */ ` + query StreamAlphabet { + alphabet(waitFor: 100) @stream + } + `); + + const executor = loader.getExecutorAsync(serverPath); + const result = await executor({ + document, + }); + + assertAsyncIterable(result); + + let i = 0; + let finalResult: ExecutionResult | undefined; + for await (const chunk of result) { + if (chunk) { + expect(chunk.data?.alphabet?.length).toBe(i); + i++; + if (i > alphabet.length) { + finalResult = chunk; + break; + } + } + } + expect(finalResult?.data?.alphabet).toEqual(alphabet); + }); + it('should handle SSE subscription result', async () => { const expectedDatas: ExecutionResult[] = [{ data: { foo: 1 } }, { data: { foo: 2 } }, { data: { foo: 3 } }]; diff --git a/packages/utils/src/Interfaces.ts b/packages/utils/src/Interfaces.ts index c2bbd4cbdfe..0dcc31838b5 100644 --- a/packages/utils/src/Interfaces.ts +++ b/packages/utils/src/Interfaces.ts @@ -64,6 +64,7 @@ export interface ExecutionResult { extensions?: TExtensions; label?: string; path?: ReadonlyArray; + items?: TData | null; } export interface ExecutionRequest< diff --git a/yarn.lock b/yarn.lock index 6aec6db0157..eb611406bb6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1416,7 +1416,7 @@ "@changesets/types" "^5.2.0" dotenv "^8.1.0" -"@changesets/cli@2.25.2": +"@changesets/cli@2.25.2", "@changesets/cli@^2.16.0": version "2.25.2" resolved "https://registry.yarnpkg.com/@changesets/cli/-/cli-2.25.2.tgz#fc5e894aa6f85c60749a035352dec3dcbd275c71" integrity sha512-ACScBJXI3kRyMd2R8n8SzfttDHi4tmKSwVwXBazJOylQItSRSF4cGmej2E4FVf/eNfGy6THkL9GzAahU9ErZrA== @@ -1676,6 +1676,16 @@ dependencies: giscus "^1.2.0" +"@graphql-tools/executor@^0.0.4": + version "0.0.4" + resolved "https://registry.yarnpkg.com/@graphql-tools/executor/-/executor-0.0.4.tgz#384aad9260a6dfb644f3f08b114d3f7196afa547" + integrity sha512-EBV3wBslLfYOJERjFHrV2iy2U0XCIsXkDKC383lw2b0mpBHZP8y7s8dDLGLxEvuPgou2qer511O/gqI2NEVUNw== + dependencies: + "@graphql-tools/utils" "9.0.0" + "@graphql-typed-document-node/core" "3.1.1" + "@repeaterjs/repeater" "3.0.4" + value-or-promise "1.0.1" + "@graphql-tools/utils@^8.5.2", "@graphql-tools/utils@^8.8.0": version "8.13.1" resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-8.13.1.tgz#b247607e400365c2cd87ff54654d4ad25a7ac491" @@ -12148,6 +12158,13 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" +value-or-promise@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/value-or-promise/-/value-or-promise-1.0.1.tgz#7021919262c7a13605da701bcbd3c9ae8219bf68" + integrity sha512-luIWMQACiZgNXrrCVX0B1Lm5bTT+osgLG/uiBMVvxYa52oqHGoF9YGpW+azBThx84N6bAm5MyaodRvsWaYmVbQ== + dependencies: + "@changesets/cli" "^2.16.0" + value-or-promise@1.0.11, value-or-promise@^1.0.11: version "1.0.11" resolved "https://registry.yarnpkg.com/value-or-promise/-/value-or-promise-1.0.11.tgz#3e90299af31dd014fe843fe309cefa7c1d94b140"