From 0c98c5995b0fd37d5dba4496bc791c49f2336296 Mon Sep 17 00:00:00 2001 From: hwillson <hugh@octonary.com> Date: Fri, 14 Aug 2020 08:12:45 -0400 Subject: [PATCH 01/15] Add new persisted-queries deps --- package-lock.json | 157 ++++++++++++++++++---------------------------- package.json | 2 + 2 files changed, 62 insertions(+), 97 deletions(-) diff --git a/package-lock.json b/package-lock.json index b2d367c07ad..81294f04224 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1291,9 +1291,9 @@ } }, "@graphql-typed-document-node/core": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.0.0.tgz", - "integrity": "sha512-lLAVh3eCxIrJLZjbgZYcKeRsXe7hWsyCLTdDzniLh/DOsAX41JgX/qXPpq9j7yoFKfUi8a+1EXWzC5Dxj9LisA==" + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.1.0.tgz", + "integrity": "sha512-wYn6r8zVZyQJ6rQaALBEln5B1pzxb9shV5Ef97kTvn6yVGrqyXVnDqnU24MXnFubR+rZjBY9NWuxX3FB2sTsjg==" }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", @@ -2271,9 +2271,9 @@ } }, "@types/babel__traverse": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.0.13.tgz", - "integrity": "sha512-i+zS7t6/s9cdQvbqKDARrcbrPvtJGlbYsMkazo03nTAK3RX9FNrLllXys22uiTGJapPOTZTQ35nHh4ISph4SLQ==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.0.14.tgz", + "integrity": "sha512-8w9szzKs14ZtBVuP6Wn7nMLRJ0D6dfB0VEBEyRgxrZ/Ln49aNMykrghM2FaNn4FJRzNppCSa0Rv9pBRM5Xc3wg==", "dev": true, "requires": { "@babel/types": "^7.3.0" @@ -2458,9 +2458,9 @@ "dev": true }, "@types/prettier": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.0.2.tgz", - "integrity": "sha512-IkVfat549ggtkZUthUzEX49562eGikhSYeVGX97SkMFn+sTZrgRewXjQ4tPKFPCykZHkX1Zfd9OoELGqKU2jJA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.1.0.tgz", + "integrity": "sha512-hiYA88aHiEIgDmeKlsyVsuQdcFn3Z2VuFd/Xm/HCnGnPD8UFU5BM128uzzRVVGEzKDKYUrRsRH9S2o+NUy/3IA==", "dev": true }, "@types/prop-types": { @@ -3702,6 +3702,11 @@ "which": "^1.2.9" } }, + "crypto-hash": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/crypto-hash/-/crypto-hash-1.3.0.tgz", + "integrity": "sha512-lyAZ0EMyjDkVvz8WOeVnuCPvKVBXcMv1l5SVqO1yC7PzTwrD/pPje/BIRbWhMoPe436U+Y2nD7f5bFx0kt+Sbg==" + }, "cssom": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", @@ -3771,20 +3776,20 @@ } }, "webidl-conversions": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", - "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", + "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", "dev": true }, "whatwg-url": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.1.0.tgz", - "integrity": "sha512-vEIkwNi9Hqt4TV9RdnaBPNt+E2Sgmo3gePebCRgZ1R7g6d23+53zCTnuB0amKI4AXq6VM8jj2DUAa0S1vjJxkw==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.2.2.tgz", + "integrity": "sha512-PcVnO6NiewhkmzV0qn7A+UZ9Xx4maNTI+O+TShmfE4pqjoCMwUMjkvoNhNHPTvgR7QH9Xt3R13iHuWy2sToFxQ==", "dev": true, "requires": { "lodash.sortby": "^4.7.0", "tr46": "^2.0.2", - "webidl-conversions": "^5.0.0" + "webidl-conversions": "^6.1.0" } } } @@ -6118,6 +6123,16 @@ } } }, + "jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "requires": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "jest-get-type": { "version": "25.2.6", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-25.2.6.tgz", @@ -7628,22 +7643,14 @@ "dev": true }, "whatwg-url": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.1.0.tgz", - "integrity": "sha512-vEIkwNi9Hqt4TV9RdnaBPNt+E2Sgmo3gePebCRgZ1R7g6d23+53zCTnuB0amKI4AXq6VM8jj2DUAa0S1vjJxkw==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.2.2.tgz", + "integrity": "sha512-PcVnO6NiewhkmzV0qn7A+UZ9Xx4maNTI+O+TShmfE4pqjoCMwUMjkvoNhNHPTvgR7QH9Xt3R13iHuWy2sToFxQ==", "dev": true, "requires": { "lodash.sortby": "^4.7.0", "tr46": "^2.0.2", - "webidl-conversions": "^5.0.0" - }, - "dependencies": { - "webidl-conversions": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", - "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", - "dev": true - } + "webidl-conversions": "^6.1.0" } } } @@ -7660,6 +7667,12 @@ "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", "dev": true }, + "json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, "json-schema": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", @@ -8583,6 +8596,12 @@ "asap": "~2.0.3" } }, + "promise-polyfill": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.1.3.tgz", + "integrity": "sha512-MG5r82wBzh7pSKDRa9y+vllNHz3e3d4CNj1PQE4BQYxLme0gKYYBm9YENq+UkEikyZ0XbiGWxYlVw3Rl9O/U8g==", + "dev": true + }, "prompts": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.3.2.tgz", @@ -8714,14 +8733,14 @@ }, "dependencies": { "parse-json": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.0.1.tgz", - "integrity": "sha512-ztoZ4/DYeXQq4E21v169sC8qWINGpcosGv9XhTDvg9/hWvx/zrFkc9BiWxR58OJLHGk28j5BL0SDLeV2WmFZlQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.1.0.tgz", + "integrity": "sha512-+mi/lmVVNKFNVyLXV31ERiy2CY5E1/F6QtJFEzoChPRwwngMNXRDQ9GJ5WdE2Z2P4AujsOi0/+2qHID68KwfIQ==", "dev": true, "requires": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", - "json-parse-better-errors": "^1.0.1", + "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, @@ -9383,54 +9402,6 @@ "jest-worker": "^26.2.1", "serialize-javascript": "^4.0.0", "terser": "^5.0.0" - }, - "dependencies": { - "@babel/code-frame": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", - "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", - "dev": true, - "requires": { - "@babel/highlight": "^7.10.4" - } - }, - "@babel/highlight": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz", - "integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==", - "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.10.4", - "chalk": "^2.0.0", - "js-tokens": "^4.0.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "requires": { - "has-flag": "^3.0.0" - } - } } }, "rsvp": { @@ -9929,9 +9900,9 @@ } }, "source-map-support": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.16.tgz", - "integrity": "sha512-efyLRJDr68D9hBBNIPWFjhpFzURh+KJykQwvMyW5UiZzYwoF6l4YMMDIJJEyFWxWCqfyxLzz6tSfUFR+kXXsVQ==", + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz", + "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==", "requires": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -10266,9 +10237,9 @@ } }, "terser": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.2.0.tgz", - "integrity": "sha512-nZ9TWhBznZdlww3borgJyfQDrxzpgd0RlRNoxR63tMVry01lIH/zKQDTTiaWRMGowydfvSHMgyiGyn6A9PSkCQ==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.2.1.tgz", + "integrity": "sha512-/AOtjRtAMNGO0fIF6m8HfcvXTw/2AKpsOzDn36tA5RfhRdeXyb4RvHxJ5Pah7iL6dFkLk+gOnCaNHGwJPl6TrQ==", "requires": { "commander": "^2.20.0", "source-map": "~0.6.1", @@ -10631,9 +10602,9 @@ } }, "uri-js": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", - "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.0.tgz", + "integrity": "sha512-B0yRTzYdUCCn9n+F4+Gh4yIDtMQcaJsmYBDsTSG8g/OejKBodLQ2IHfN3bM7jUsRXndopT7OIXWdYqc1fjmV6g==", "dev": true, "requires": { "punycode": "^2.1.0" @@ -11052,14 +11023,6 @@ "requires": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" - }, - "dependencies": { - "decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", - "dev": true - } } }, "yn": { diff --git a/package.json b/package.json index 201d12a267a..95f80761f17 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@types/zen-observable": "^0.8.0", "@wry/context": "^0.5.2", "@wry/equality": "^0.2.0", + "crypto-hash": "^1.3.0", "fast-json-stable-stringify": "^2.0.0", "graphql-tag": "^2.11.0", "hoist-non-react-statics": "^3.3.2", @@ -103,6 +104,7 @@ "graphql": "15.3.0", "graphql-tools": "^6.0.12", "jest": "26.4.2", + "jest-fetch-mock": "^3.0.3", "jest-junit": "11.1.0", "lodash": "4.17.20", "react": "^16.13.1", From 402905367dfb299ee714e5150077bd9c85d0ab10 Mon Sep 17 00:00:00 2001 From: hwillson <hugh@octonary.com> Date: Fri, 14 Aug 2020 08:13:46 -0400 Subject: [PATCH 02/15] Initial persisted-queries code migration with modifications This code was migrated from the `apollo-link-persisted-queries` repo, and modified to work with Apollo Client 3. --- src/link/persisted-queries/__tests__/index.ts | 332 ++++++++++++++++++ .../persisted-queries/__tests__/react.tsx | 133 +++++++ src/link/persisted-queries/index.ts | 224 ++++++++++++ 3 files changed, 689 insertions(+) create mode 100644 src/link/persisted-queries/__tests__/index.ts create mode 100644 src/link/persisted-queries/__tests__/react.tsx create mode 100644 src/link/persisted-queries/index.ts diff --git a/src/link/persisted-queries/__tests__/index.ts b/src/link/persisted-queries/__tests__/index.ts new file mode 100644 index 00000000000..96d6571c911 --- /dev/null +++ b/src/link/persisted-queries/__tests__/index.ts @@ -0,0 +1,332 @@ +import gql from 'graphql-tag'; +import { sha256 } from 'crypto-hash'; +import { print } from 'graphql'; +import { times } from 'lodash'; +import fetch from 'jest-fetch-mock'; + +import { ApolloLink, execute } from '../../core'; +import { Observable } from '../../../utilities'; +import { createHttpLink } from '../../http/createHttpLink'; + +import { createPersistedQueryLink as createPersistedQuery, VERSION } from '../'; + +global.fetch = fetch; + +const makeAliasFields = (fieldName: string, numAliases: number) => + times(numAliases, idx => `${fieldName}${idx}: ${fieldName}`).reduce( + (aliasBody, currentAlias) => `${aliasBody}\n ${currentAlias}`, + ); + +const query = gql` + query Test($id: ID!) { + foo(id: $id) { + bar + ${makeAliasFields('title', 1000)} + } + } +`; + +const variables = { id: 1 }; +const queryString = print(query); +const data = { + foo: { bar: true }, +}; +const response = JSON.stringify({ data }); +const errors = [{ message: 'PersistedQueryNotFound' }]; +const giveUpErrors = [{ message: 'PersistedQueryNotSupported' }]; +const multipleErrors = [...errors, { message: 'not logged in' }]; +const errorResponse = JSON.stringify({ errors }); +const giveUpResponse = JSON.stringify({ errors: giveUpErrors }); +const multiResponse = JSON.stringify({ errors: multipleErrors }); + +let hash: string; +(async () => { + hash = await sha256(queryString); +})(); + +describe('happy path', () => { + beforeEach(fetch.mockReset); + it('sends a sha256 hash of the query under extensions', done => { + fetch.mockResponseOnce(response); + const link = createPersistedQuery().concat(createHttpLink()); + execute(link, { query, variables }).subscribe(result => { + expect(result.data).toEqual(data); + const [uri, request] = fetch.mock.calls[0]; + expect(uri).toEqual('/graphql'); + expect(request!.body!).toBe( + JSON.stringify({ + operationName: 'Test', + variables, + extensions: { + persistedQuery: { + version: VERSION, + sha256Hash: hash, + }, + }, + }), + ); + done(); + }, done.fail); + }); + it('sends a version along with the request', done => { + fetch.mockResponseOnce(response); + const link = createPersistedQuery().concat(createHttpLink()); + + execute(link, { query, variables }).subscribe(result => { + expect(result.data).toEqual(data); + const [uri, request] = fetch.mock.calls[0]; + expect(uri).toEqual('/graphql'); + const parsed = JSON.parse(request!.body!.toString()); + expect(parsed.extensions.persistedQuery.version).toBe(VERSION); + done(); + }, done.fail); + }); + it('memoizes between requests', done => { + fetch.mockResponseOnce(response); + fetch.mockResponseOnce(response); + const link = createPersistedQuery().concat(createHttpLink()); + + let start = new Date(); + execute(link, { query, variables }).subscribe(result => { + const firstRun = new Date().valueOf() - start.valueOf(); + expect(result.data).toEqual(data); + // this one should go faster becuase of memoization + let secondStart = new Date(); + execute(link, { query, variables }).subscribe(result2 => { + const secondRun = new Date().valueOf() - secondStart.valueOf(); + expect(firstRun).toBeGreaterThan(secondRun); + expect(result2.data).toEqual(data); + done(); + }, done.fail); + }, done.fail); + }); + it('supports loading the hash from other method', done => { + fetch.mockResponseOnce(response); + const generateHash = + (query: any) => Promise.resolve('foo'); + const link = createPersistedQuery({ generateHash }).concat( + createHttpLink(), + ); + + execute(link, { query, variables }).subscribe(result => { + expect(result.data).toEqual(data); + const [uri, request] = fetch.mock.calls[0]; + expect(uri).toEqual('/graphql'); + const parsed = JSON.parse(request!.body!.toString()); + expect(parsed.extensions.persistedQuery.sha256Hash).toBe('foo'); + done(); + }, done.fail); + }); + + it('errors if unable to convert to sha256', done => { + fetch.mockResponseOnce(response); + const link = createPersistedQuery().concat(createHttpLink()); + + execute(link, { query: '1234', variables } as any).subscribe(done.fail as any, error => { + expect(error.message).toMatch(/Invalid AST Node/); + done(); + }); + }); + it('unsubscribes correctly', done => { + const delay = new ApolloLink(() => { + return new Observable(ob => { + setTimeout(() => { + ob.next({ data }); + ob.complete(); + }, 100); + }); + }); + const link = createPersistedQuery().concat(delay); + + const sub = execute(link, { query, variables }).subscribe( + done.fail as any, + done.fail, + done.fail, + ); + + setTimeout(() => { + sub.unsubscribe(); + done(); + }, 10); + }); +}); +describe('failure path', () => { + beforeEach(fetch.mockReset); + it('correctly identifies the error shape from the server', done => { + fetch.mockResponseOnce(errorResponse); + fetch.mockResponseOnce(response); + const link = createPersistedQuery().concat(createHttpLink()); + execute(link, { query, variables }).subscribe(result => { + expect(result.data).toEqual(data); + const [, failure] = fetch.mock.calls[0]; + expect(JSON.parse(failure!.body!.toString()).query).not.toBeDefined(); + const [, success] = fetch.mock.calls[1]; + expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); + expect( + JSON.parse(success!.body!.toString()).extensions.persistedQuery.sha256Hash, + ).toBe(hash); + done(); + }, done.fail); + }); + it('sends GET for the first response only with useGETForHashedQueries', done => { + fetch.mockResponseOnce(errorResponse); + fetch.mockResponseOnce(response); + const link = createPersistedQuery({ useGETForHashedQueries: true }).concat( + createHttpLink(), + ); + execute(link, { query, variables }).subscribe(result => { + expect(result.data).toEqual(data); + const [, failure] = fetch.mock.calls[0]; + expect(failure!.method).toBe('GET'); + expect(failure!.body).not.toBeDefined(); + const [, success] = fetch.mock.calls[1]; + expect(success!.method).toBe('POST'); + expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); + expect( + JSON.parse(success!.body!.toString()).extensions.persistedQuery.sha256Hash, + ).toBe(hash); + done(); + }, done.fail); + }); + it('does not try again after receiving NotSupported error', done => { + fetch.mockResponseOnce(giveUpResponse); + fetch.mockResponseOnce(response); + + // mock it again so we can verify it doesn't try anymore + fetch.mockResponseOnce(response); + const link = createPersistedQuery().concat(createHttpLink()); + + execute(link, { query, variables }).subscribe(result => { + expect(result.data).toEqual(data); + const [, failure] = fetch.mock.calls[0]; + expect(JSON.parse(failure!.body!.toString()).query).not.toBeDefined(); + const [, success] = fetch.mock.calls[1]; + expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); + expect(JSON.parse(success!.body!.toString()).extensions).toBeUndefined(); + execute(link, { query, variables }).subscribe(secondResult => { + expect(secondResult.data).toEqual(data); + + const [, success] = fetch.mock.calls[2]; + expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); + expect(JSON.parse(success!.body!.toString()).extensions).toBeUndefined(); + done(); + }, done.fail); + }, done.fail); + }); + + it('works with multiple errors', done => { + fetch.mockResponseOnce(multiResponse); + fetch.mockResponseOnce(response); + const link = createPersistedQuery().concat(createHttpLink()); + execute(link, { query, variables }).subscribe(result => { + expect(result.data).toEqual(data); + const [, failure] = fetch.mock.calls[0]; + expect(JSON.parse(failure!.body!.toString()).query).not.toBeDefined(); + const [, success] = fetch.mock.calls[1]; + expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); + expect( + JSON.parse(success!.body!.toString()).extensions.persistedQuery.sha256Hash, + ).toBe(hash); + done(); + }, done.fail); + }); + it('handles a 500 network error and still retries', done => { + let failed = false; + fetch.mockResponseOnce(response); + + // mock it again so we can verify it doesn't try anymore + fetch.mockResponseOnce(response); + + const fetcher = (...args: any[]) => { + if (!failed) { + failed = true; + return Promise.resolve({ + json: () => Promise.resolve('This will blow up'), + text: () => Promise.resolve('THIS WILL BLOW UP'), + status: 500, + }); + } + + return fetch(...args); + }; + const link = createPersistedQuery().concat( + createHttpLink({ fetch: fetcher } as any), + ); + + execute(link, { query, variables }).subscribe(result => { + expect(result.data).toEqual(data); + const [, success] = fetch.mock.calls[0]; + expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); + expect(JSON.parse(success!.body!.toString()).extensions).toBeUndefined(); + execute(link, { query, variables }).subscribe(secondResult => { + expect(secondResult.data).toEqual(data); + + const [, success] = fetch.mock.calls[1]; + expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); + expect(JSON.parse(success!.body!.toString()).extensions).toBeUndefined(); + done(); + }, done.fail); + }, done.fail); + }); + it('handles a 400 network error and still retries', done => { + let failed = false; + fetch.mockResponseOnce(response); + + // mock it again so we can verify it doesn't try anymore + fetch.mockResponseOnce(response); + + const fetcher = (...args: any[]) => { + if (!failed) { + failed = true; + return Promise.resolve({ + json: () => Promise.resolve('This will blow up'), + text: () => Promise.resolve('THIS WILL BLOW UP'), + status: 400, + }); + } + + return fetch(...args); + }; + const link = createPersistedQuery().concat( + createHttpLink({ fetch: fetcher } as any), + ); + + execute(link, { query, variables }).subscribe(result => { + expect(result.data).toEqual(data); + const [, success] = fetch.mock.calls[0]; + expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); + expect(JSON.parse(success!.body!.toString()).extensions).toBeUndefined(); + execute(link, { query, variables }).subscribe(secondResult => { + expect(secondResult.data).toEqual(data); + + const [, success] = fetch.mock.calls[1]; + expect(JSON.parse(success!.body!.toString()).query).toBe(queryString); + expect(JSON.parse(success!.body!.toString()).extensions).toBeUndefined(); + done(); + }, done.fail); + }, done.fail); + }); + + it('only retries a 400 network error once', done => { + let fetchCalls = 0; + const fetcher = () => { + fetchCalls++; + return Promise.resolve({ + json: () => Promise.resolve('This will blow up'), + text: () => Promise.resolve('THIS WILL BLOW UP'), + status: 400, + }); + }; + const link = createPersistedQuery().concat( + createHttpLink({ fetch: fetcher } as any), + ); + + execute(link, { query, variables }).subscribe( + result => done.fail, + error => { + expect(fetchCalls).toBe(2); + done(); + }, + ); + }); +}); diff --git a/src/link/persisted-queries/__tests__/react.tsx b/src/link/persisted-queries/__tests__/react.tsx new file mode 100644 index 00000000000..787de91d58e --- /dev/null +++ b/src/link/persisted-queries/__tests__/react.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import ReactDOM from 'react-dom/server'; +import gql from 'graphql-tag'; +import { print } from 'graphql'; +import { sha256 } from 'crypto-hash'; +import fetch from 'jest-fetch-mock'; + +import { ApolloProvider } from '../../../react/context'; +import { InMemoryCache as Cache } from '../../../cache/inmemory/inMemoryCache'; +import { ApolloClient } from '../../../core/ApolloClient'; +import { createHttpLink } from '../../http/createHttpLink'; +import { graphql } from '../../../react/hoc/graphql'; +import { getDataFromTree } from '../../../react/ssr/getDataFromTree'; +import { createPersistedQueryLink as createPersistedQuery, VERSION } from '../'; + +global.fetch = fetch; + +const query = gql` + query Test($filter: FilterObject) { + foo(filter: $filter) { + bar + } + } +`; + +const variables = { + filter: { + $filter: 'smash', + }, +}; +const variables2 = { + filter: null, +}; +const data = { + foo: { bar: true }, +}; +const data2 = { + foo: { bar: false }, +}; +const response = JSON.stringify({ data }); +const response2 = JSON.stringify({ data: data2 }); +const queryString = print(query); + +let hash: string; +(async () => { + hash = await sha256(queryString); +})(); + +describe('react application', () => { + beforeEach(fetch.mockReset); + it('works on a simple tree', async () => { + fetch.mockResponseOnce(response); + fetch.mockResponseOnce(response2); + + const link = createPersistedQuery().concat(createHttpLink()); + + const client = new ApolloClient({ + link, + cache: new Cache({ addTypename: false }), + ssrMode: true, + }); + + const Query = graphql(query)(({ data, children }) => { + if (data!.loading) return null; + + return ( + <div> + {(data as any).foo.bar && 'data was returned!'} + {children} + </div> + ); + }); + const app = ( + <ApolloProvider client={client}> + <Query {...variables}> + <h1>Hello!</h1> + </Query> + </ApolloProvider> + ); + + // preload all the data for client side request (with filter) + const result = await getDataFromTree(app); + expect(result).toContain('data was returned'); + let [, request] = fetch.mock.calls[0]; + expect(request!.body).toBe( + JSON.stringify({ + operationName: 'Test', + variables, + extensions: { + persistedQuery: { + version: VERSION, + sha256Hash: hash, + }, + }, + }), + ); + + // reset client and try with different input object + const client2 = new ApolloClient({ + link, + cache: new Cache({ addTypename: false }), + ssrMode: true, + }); + + const app2 = ( + <ApolloProvider client={client2}> + <Query {...variables2}> + <h1>Hello!</h1> + </Query> + </ApolloProvider> + ); + + // change filter object to different variables and SSR + await getDataFromTree(app2); + const markup2 = ReactDOM.renderToString(app2); + + let [, request2] = fetch.mock.calls[1]; + + expect(markup2).not.toContain('data was returned'); + expect(request2!.body).toBe( + JSON.stringify({ + operationName: 'Test', + variables: variables2, + extensions: { + persistedQuery: { + version: VERSION, + sha256Hash: hash, + }, + }, + }), + ); + }); +}); diff --git a/src/link/persisted-queries/index.ts b/src/link/persisted-queries/index.ts new file mode 100644 index 00000000000..1542dd557ed --- /dev/null +++ b/src/link/persisted-queries/index.ts @@ -0,0 +1,224 @@ +import { sha256 } from 'crypto-hash'; +import { print } from 'graphql/language/printer'; +import { + DefinitionNode, + DocumentNode, + ExecutionResult, + GraphQLError, +} from 'graphql'; + +import { ApolloLink, Operation } from '../core'; +import { Observable, Observer } from '../../utilities'; + +export const VERSION = 1; + +export interface ErrorResponse { + graphQLErrors?: readonly GraphQLError[]; + networkError?: Error; + response?: ExecutionResult; + operation: Operation; +} + +namespace PersistedQueryLink { + export type Options = { + generateHash?: (document: DocumentNode) => Promise<string>; + disable?: (error: ErrorResponse) => boolean; + useGETForHashedQueries?: boolean; + }; +} + +export const defaultGenerateHash = + (query: DocumentNode): Promise<string> => sha256(print(query)); + +export const defaultOptions = { + generateHash: defaultGenerateHash, + disable: ({ graphQLErrors, operation }: ErrorResponse) => { + // if the server doesn't support persisted queries, don't try anymore + if ( + graphQLErrors && + graphQLErrors.some( + ({ message }) => message === 'PersistedQueryNotSupported', + ) + ) { + return true; + } + + const { response } = operation.getContext(); + // if the server responds with bad request + // apollo-server responds with 400 for GET and 500 for POST when no query is found + if ( + response && + response.status && + (response.status === 400 || response.status === 500) + ) { + return true; + } + + return false; + }, + useGETForHashedQueries: false, +}; + +function definitionIsMutation(d: DefinitionNode) { + return d.kind === 'OperationDefinition' && d.operation === 'mutation'; +} + +// Note that this also returns true for subscriptions. +function operationIsQuery(operation: Operation) { + return !operation.query.definitions.some(definitionIsMutation); +} + +const { hasOwnProperty } = Object.prototype; +const hashesKeyString = '__createPersistedQueryLink_hashes'; +const hashesKey = + typeof Symbol === 'function' ? Symbol.for(hashesKeyString) : hashesKeyString; +let nextHashesChildKey = 0; + +export const createPersistedQueryLink = ( + options: PersistedQueryLink.Options = {}, +) => { + const { generateHash, disable, useGETForHashedQueries } = Object.assign( + {}, + defaultOptions, + options, + ); + let supportsPersistedQueries = true; + + const hashesChildKey = 'forLink' + nextHashesChildKey++; + function getQueryHash(query: DocumentNode): Promise<string> { + if (!query || typeof query !== 'object') { + // If the query is not an object, we won't be able to store its hash as + // a property of query[hashesKey], so we let generateHash(query) decide + // what to do with the bogus query. + return generateHash(query); + } + if (!hasOwnProperty.call(query, hashesKey)) { + Object.defineProperty(query, hashesKey, { + value: Object.create(null), + enumerable: false, + }); + } + const hashes = (query as any)[hashesKey]; + return hasOwnProperty.call(hashes, hashesChildKey) + ? hashes[hashesChildKey] + : (hashes[hashesChildKey] = generateHash(query)); + } + + return new ApolloLink((operation, forward) => { + if (!forward) { + throw new Error( + 'PersistedQueryLink cannot be the last link in the chain.', + ); + } + + const { query } = operation; + + return new Observable((observer: Observer<ExecutionResult>) => { + let subscription: ZenObservable.Subscription; + let retried = false; + let originalFetchOptions: any; + let setFetchOptions = false; + const retry = ( + { + response, + networkError, + }: { response?: ExecutionResult; networkError?: Error }, + cb: () => void, + ) => { + if (!retried && ((response && response.errors) || networkError)) { + retried = true; + + const disablePayload = { + response, + networkError, + operation, + graphQLErrors: response ? response.errors : undefined, + }; + + // if the server doesn't support persisted queries, don't try anymore + supportsPersistedQueries = !disable(disablePayload); + + // if its not found, we can try it again, otherwise just report the error + if ( + (response && + response.errors && + response.errors.some( + ({ message }: { message: string }) => + message === 'PersistedQueryNotFound', + )) || + !supportsPersistedQueries + ) { + // need to recall the link chain + if (subscription) subscription.unsubscribe(); + // actually send the query this time + operation.setContext({ + http: { + includeQuery: true, + includeExtensions: supportsPersistedQueries, + }, + }); + if (setFetchOptions) { + operation.setContext({ fetchOptions: originalFetchOptions }); + } + subscription = forward(operation).subscribe(handler); + + return; + } + } + cb(); + }; + const handler = { + next: (response: ExecutionResult) => { + retry({ response }, () => observer.next!(response)); + }, + error: (networkError: Error) => { + retry({ networkError }, () => observer.error!(networkError)); + }, + complete: observer.complete!.bind(observer), + }; + + // don't send the query the first time + operation.setContext({ + http: { + includeQuery: !supportsPersistedQueries, + includeExtensions: supportsPersistedQueries, + }, + }); + + // If requested, set method to GET if there are no mutations. Remember the + // original fetchOptions so we can restore them if we fall back to a + // non-hashed request. + if ( + useGETForHashedQueries && + supportsPersistedQueries && + operationIsQuery(operation) + ) { + operation.setContext( + ({ fetchOptions = {} }: { fetchOptions: Record<string, any> }) => { + originalFetchOptions = fetchOptions; + return { + fetchOptions: Object.assign({}, fetchOptions, { method: 'GET' }), + }; + }, + ); + setFetchOptions = true; + } + + if (supportsPersistedQueries) { + getQueryHash(query).then((sha256Hash) => { + operation.extensions.persistedQuery = { + version: VERSION, + sha256Hash, + }; + subscription = forward(operation).subscribe(handler); + }).catch(observer.error!.bind(observer));; + } else { + subscription = forward(operation).subscribe(handler); + } + + return () => { + if (subscription) subscription.unsubscribe(); + }; + }); + }); +}; From ad01106b7f978154095c40184d5bb2894a9f651b Mon Sep 17 00:00:00 2001 From: hwillson <hugh@octonary.com> Date: Fri, 14 Aug 2020 08:21:28 -0400 Subject: [PATCH 03/15] Add the persisted-queries entry point --- config/entryPoints.js | 1 + 1 file changed, 1 insertion(+) diff --git a/config/entryPoints.js b/config/entryPoints.js index 4c1a5cf1396..cb016135b90 100644 --- a/config/entryPoints.js +++ b/config/entryPoints.js @@ -9,6 +9,7 @@ const entryPoints = [ { dirs: ['link', 'core'] }, { dirs: ['link', 'error'] }, { dirs: ['link', 'http'] }, + { dirs: ['link', 'persisted-queries'] }, { dirs: ['link', 'retry'] }, { dirs: ['link', 'schema'] }, { dirs: ['link', 'utils'] }, From b951c8faa8bfc55e15c7c1afb6a87f2c5036addc Mon Sep 17 00:00:00 2001 From: hwillson <hugh@octonary.com> Date: Fri, 14 Aug 2020 10:47:40 -0400 Subject: [PATCH 04/15] Add @apollo/client/link/persisted-queries to the exports test --- src/__tests__/__snapshots__/exports.ts.snap | 9 +++++++++ src/__tests__/exports.ts | 2 ++ 2 files changed, 11 insertions(+) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index 6f7153ca44b..f0ad6a72cf5 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -174,6 +174,15 @@ Array [ ] `; +exports[`exports of public entry points @apollo/client/link/persisted-queries 1`] = ` +Array [ + "VERSION", + "createPersistedQueryLink", + "defaultGenerateHash", + "defaultOptions", +] +`; + exports[`exports of public entry points @apollo/client/link/retry 1`] = ` Array [ "RetryLink", diff --git a/src/__tests__/exports.ts b/src/__tests__/exports.ts index 5f896488d05..13e00747a14 100644 --- a/src/__tests__/exports.ts +++ b/src/__tests__/exports.ts @@ -8,6 +8,7 @@ import * as linkContext from "../link/context"; import * as linkCore from "../link/core"; import * as linkError from "../link/error"; import * as linkHTTP from "../link/http"; +import * as linkPersistedQueries from "../link/persisted-queries"; import * as linkRetry from "../link/retry"; import * as linkSchema from "../link/schema"; import * as linkUtils from "../link/utils"; @@ -47,6 +48,7 @@ describe('exports of public entry points', () => { check("@apollo/client/link/core", linkCore); check("@apollo/client/link/error", linkError); check("@apollo/client/link/http", linkHTTP); + check("@apollo/client/link/persisted-queries", linkPersistedQueries); check("@apollo/client/link/retry", linkRetry); check("@apollo/client/link/schema", linkSchema); check("@apollo/client/link/utils", linkUtils); From e255e7671e87e3dea345d2ab52d290538662542f Mon Sep 17 00:00:00 2001 From: hwillson <hugh@octonary.com> Date: Mon, 24 Aug 2020 07:46:50 -0400 Subject: [PATCH 05/15] Make `crypto-hash` a dev dep We've decided to avoid falling back in a particular SHA-256 function by default, to avoid bundling one with Apollo Client. `crypto-hash` is now only using for testing. --- package-lock.json | 3 ++- package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 81294f04224..af127d9270e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3705,7 +3705,8 @@ "crypto-hash": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/crypto-hash/-/crypto-hash-1.3.0.tgz", - "integrity": "sha512-lyAZ0EMyjDkVvz8WOeVnuCPvKVBXcMv1l5SVqO1yC7PzTwrD/pPje/BIRbWhMoPe436U+Y2nD7f5bFx0kt+Sbg==" + "integrity": "sha512-lyAZ0EMyjDkVvz8WOeVnuCPvKVBXcMv1l5SVqO1yC7PzTwrD/pPje/BIRbWhMoPe436U+Y2nD7f5bFx0kt+Sbg==", + "dev": true }, "cssom": { "version": "0.4.4", diff --git a/package.json b/package.json index 95f80761f17..5834242f98e 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,6 @@ "@types/zen-observable": "^0.8.0", "@wry/context": "^0.5.2", "@wry/equality": "^0.2.0", - "crypto-hash": "^1.3.0", "fast-json-stable-stringify": "^2.0.0", "graphql-tag": "^2.11.0", "hoist-non-react-statics": "^3.3.2", @@ -99,6 +98,7 @@ "@types/recompose": "^0.30.7", "bundlesize": "0.18.0", "cross-fetch": "3.0.5", + "crypto-hash": "^1.3.0", "fetch-mock": "7.7.3", "glob": "7.1.6", "graphql": "15.3.0", From 802a0ba453937f633701ca821c3ca81e55ffcae8 Mon Sep 17 00:00:00 2001 From: hwillson <hugh@octonary.com> Date: Mon, 24 Aug 2020 07:59:17 -0400 Subject: [PATCH 06/15] Ensure a `sha256` or `generateHash` function is provided We're no longer providing a SHA-256 function by default, so these changes ensure that an appropriate function is provided by developers. Developers can choose to supply a SHA-256 function via the `sha256` option, if they want the default hash generation capabilities of the Link (e.g. sha256(print(query))), or they can override the hashing process completely by supplying a custom `generateHash` function. --- src/link/persisted-queries/index.ts | 82 ++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 20 deletions(-) diff --git a/src/link/persisted-queries/index.ts b/src/link/persisted-queries/index.ts index 1542dd557ed..573333580e0 100644 --- a/src/link/persisted-queries/index.ts +++ b/src/link/persisted-queries/index.ts @@ -1,4 +1,3 @@ -import { sha256 } from 'crypto-hash'; import { print } from 'graphql/language/printer'; import { DefinitionNode, @@ -6,6 +5,7 @@ import { ExecutionResult, GraphQLError, } from 'graphql'; +import { invariant } from 'ts-invariant'; import { ApolloLink, Operation } from '../core'; import { Observable, Observer } from '../../utilities'; @@ -19,19 +19,29 @@ export interface ErrorResponse { operation: Operation; } +type SHA256Function = (...args: any[]) => string | Promise<string>; +type GenerateHashFunction = (document: DocumentNode) => Promise<string>; + namespace PersistedQueryLink { - export type Options = { - generateHash?: (document: DocumentNode) => Promise<string>; + interface BaseOptions { disable?: (error: ErrorResponse) => boolean; useGETForHashedQueries?: boolean; }; -} -export const defaultGenerateHash = - (query: DocumentNode): Promise<string> => sha256(print(query)); + interface SHA256Options extends BaseOptions { + sha256: SHA256Function; + generateHash?: never; + }; + + interface GenerateHashOptions extends BaseOptions { + sha256?: never; + generateHash: GenerateHashFunction; + }; + + export type Options = SHA256Options | GenerateHashOptions; +} export const defaultOptions = { - generateHash: defaultGenerateHash, disable: ({ graphQLErrors, operation }: ErrorResponse) => { // if the server doesn't support persisted queries, don't try anymore if ( @@ -68,6 +78,26 @@ function operationIsQuery(operation: Operation) { return !operation.query.definitions.some(definitionIsMutation); } +// Ensure a SHA-256 hash function is provided, if a custom hash generation +// function is not provided. We don't supply a SHA-256 hash function by +// default, to avoid forcing one as a dependency. Developers should pick the +// most appropriate SHA-256 function (sync or async) for their +// needs/environment, or provide a fully custom hash generation function +// (via the `generateHash` option) if they want to handle hashing with +// something other than SHA-256. +function verifyHashFunction( + sha256?: SHA256Function, + generateHash?: GenerateHashFunction +) { + invariant( + (sha256 && typeof sha256 === 'function') || + (generateHash && typeof generateHash === 'function'), + 'Missing/invalid "sha256" or "generateHash" function. Please ' + + 'configure one using the "createPersistedQueryLink(options)" options ' + + 'parameter. ' + ); +} + const { hasOwnProperty } = Object.prototype; const hashesKeyString = '__createPersistedQueryLink_hashes'; const hashesKey = @@ -75,13 +105,26 @@ const hashesKey = let nextHashesChildKey = 0; export const createPersistedQueryLink = ( - options: PersistedQueryLink.Options = {}, + options: PersistedQueryLink.Options, ) => { - const { generateHash, disable, useGETForHashedQueries } = Object.assign( - {}, - defaultOptions, - options, - ); + const { + sha256, + generateHash, + disable, + useGETForHashedQueries + } = Object.assign({}, defaultOptions, options); + + verifyHashFunction(sha256, generateHash); + + // If both a `sha256` and `generateHash` option are provided, the + // `sha256` option will be ignored. Developers can configure and + // use any hashing approach they want in a custom `generateHash` + // function; they aren't limited to SHA-256. + const hash = + generateHash || + ((query: DocumentNode): Promise<string> => + Promise.resolve(sha256!(print(query)))); + let supportsPersistedQueries = true; const hashesChildKey = 'forLink' + nextHashesChildKey++; @@ -90,7 +133,7 @@ export const createPersistedQueryLink = ( // If the query is not an object, we won't be able to store its hash as // a property of query[hashesKey], so we let generateHash(query) decide // what to do with the bogus query. - return generateHash(query); + return hash(query); } if (!hasOwnProperty.call(query, hashesKey)) { Object.defineProperty(query, hashesKey, { @@ -101,15 +144,14 @@ export const createPersistedQueryLink = ( const hashes = (query as any)[hashesKey]; return hasOwnProperty.call(hashes, hashesChildKey) ? hashes[hashesChildKey] - : (hashes[hashesChildKey] = generateHash(query)); + : (hashes[hashesChildKey] = hash(query)); } return new ApolloLink((operation, forward) => { - if (!forward) { - throw new Error( - 'PersistedQueryLink cannot be the last link in the chain.', - ); - } + invariant( + forward, + 'PersistedQueryLink cannot be the last link in the chain.' + ); const { query } = operation; From 2f7abb5d04e6ea3c3bbfff6045e475df18df9181 Mon Sep 17 00:00:00 2001 From: hwillson <hugh@octonary.com> Date: Mon, 24 Aug 2020 08:05:37 -0400 Subject: [PATCH 07/15] Adjust tests to accommodate required sha256/generateHash options These changes also ensure that the tests cover supplying a sync or async SHA-256 function. --- src/__tests__/__snapshots__/exports.ts.snap | 1 - src/link/persisted-queries/__tests__/index.ts | 99 ++++++++++++++++--- .../persisted-queries/__tests__/react.tsx | 2 +- 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index f0ad6a72cf5..b48d7db2459 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -178,7 +178,6 @@ exports[`exports of public entry points @apollo/client/link/persisted-queries 1` Array [ "VERSION", "createPersistedQueryLink", - "defaultGenerateHash", "defaultOptions", ] `; diff --git a/src/link/persisted-queries/__tests__/index.ts b/src/link/persisted-queries/__tests__/index.ts index 96d6571c911..1779aa3416e 100644 --- a/src/link/persisted-queries/__tests__/index.ts +++ b/src/link/persisted-queries/__tests__/index.ts @@ -46,9 +46,10 @@ let hash: string; describe('happy path', () => { beforeEach(fetch.mockReset); + it('sends a sha256 hash of the query under extensions', done => { fetch.mockResponseOnce(response); - const link = createPersistedQuery().concat(createHttpLink()); + const link = createPersistedQuery({ sha256 }).concat(createHttpLink()); execute(link, { query, variables }).subscribe(result => { expect(result.data).toEqual(data); const [uri, request] = fetch.mock.calls[0]; @@ -68,9 +69,10 @@ describe('happy path', () => { done(); }, done.fail); }); + it('sends a version along with the request', done => { fetch.mockResponseOnce(response); - const link = createPersistedQuery().concat(createHttpLink()); + const link = createPersistedQuery({ sha256 }).concat(createHttpLink()); execute(link, { query, variables }).subscribe(result => { expect(result.data).toEqual(data); @@ -81,10 +83,11 @@ describe('happy path', () => { done(); }, done.fail); }); + it('memoizes between requests', done => { fetch.mockResponseOnce(response); fetch.mockResponseOnce(response); - const link = createPersistedQuery().concat(createHttpLink()); + const link = createPersistedQuery({ sha256 }).concat(createHttpLink()); let start = new Date(); execute(link, { query, variables }).subscribe(result => { @@ -100,6 +103,7 @@ describe('happy path', () => { }, done.fail); }, done.fail); }); + it('supports loading the hash from other method', done => { fetch.mockResponseOnce(response); const generateHash = @@ -120,13 +124,14 @@ describe('happy path', () => { it('errors if unable to convert to sha256', done => { fetch.mockResponseOnce(response); - const link = createPersistedQuery().concat(createHttpLink()); + const link = createPersistedQuery({ sha256 }).concat(createHttpLink()); execute(link, { query: '1234', variables } as any).subscribe(done.fail as any, error => { expect(error.message).toMatch(/Invalid AST Node/); done(); }); }); + it('unsubscribes correctly', done => { const delay = new ApolloLink(() => { return new Observable(ob => { @@ -136,7 +141,7 @@ describe('happy path', () => { }, 100); }); }); - const link = createPersistedQuery().concat(delay); + const link = createPersistedQuery({ sha256 }).concat(delay); const sub = execute(link, { query, variables }).subscribe( done.fail as any, @@ -149,13 +154,79 @@ describe('happy path', () => { done(); }, 10); }); + + it('should error if `sha256` and `generateHash` options are both missing', () => { + const createPersistedQueryFn = createPersistedQuery as any; + try { + createPersistedQueryFn(); + fail('should have thrown an error'); + } catch (error) { + expect( + error.message.indexOf( + 'Missing/invalid "sha256" or "generateHash" function' + ) + ).toBe(0); + } + }); + + it('should error if `sha256` or `generateHash` options are not functions', () => { + const createPersistedQueryFn = createPersistedQuery as any; + [ + { sha256: 'ooops' }, + { generateHash: 'ooops' } + ].forEach(options => { + try { + createPersistedQueryFn(options); + fail('should have thrown an error'); + } catch (error) { + expect( + error.message.indexOf( + 'Missing/invalid "sha256" or "generateHash" function' + ) + ).toBe(0); + } + }); + }); + + it('should work with a synchronous SHA-256 function', done => { + const crypto = require('crypto'); + const sha256Hash = crypto.createHmac('sha256', queryString).digest('hex'); + + fetch.mockResponseOnce(response); + const link = createPersistedQuery({ + sha256(data) { + return crypto.createHmac('sha256', data).digest('hex'); + } + }).concat(createHttpLink()); + + execute(link, { query, variables }).subscribe(result => { + expect(result.data).toEqual(data); + const [uri, request] = fetch.mock.calls[0]; + expect(uri).toEqual('/graphql'); + expect(request!.body!).toBe( + JSON.stringify({ + operationName: 'Test', + variables, + extensions: { + persistedQuery: { + version: VERSION, + sha256Hash: sha256Hash, + }, + }, + }), + ); + done(); + }, done.fail); + }); }); + describe('failure path', () => { beforeEach(fetch.mockReset); + it('correctly identifies the error shape from the server', done => { fetch.mockResponseOnce(errorResponse); fetch.mockResponseOnce(response); - const link = createPersistedQuery().concat(createHttpLink()); + const link = createPersistedQuery({ sha256 }).concat(createHttpLink()); execute(link, { query, variables }).subscribe(result => { expect(result.data).toEqual(data); const [, failure] = fetch.mock.calls[0]; @@ -168,10 +239,11 @@ describe('failure path', () => { done(); }, done.fail); }); + it('sends GET for the first response only with useGETForHashedQueries', done => { fetch.mockResponseOnce(errorResponse); fetch.mockResponseOnce(response); - const link = createPersistedQuery({ useGETForHashedQueries: true }).concat( + const link = createPersistedQuery({ sha256, useGETForHashedQueries: true }).concat( createHttpLink(), ); execute(link, { query, variables }).subscribe(result => { @@ -188,13 +260,14 @@ describe('failure path', () => { done(); }, done.fail); }); + it('does not try again after receiving NotSupported error', done => { fetch.mockResponseOnce(giveUpResponse); fetch.mockResponseOnce(response); // mock it again so we can verify it doesn't try anymore fetch.mockResponseOnce(response); - const link = createPersistedQuery().concat(createHttpLink()); + const link = createPersistedQuery({ sha256 }).concat(createHttpLink()); execute(link, { query, variables }).subscribe(result => { expect(result.data).toEqual(data); @@ -217,7 +290,7 @@ describe('failure path', () => { it('works with multiple errors', done => { fetch.mockResponseOnce(multiResponse); fetch.mockResponseOnce(response); - const link = createPersistedQuery().concat(createHttpLink()); + const link = createPersistedQuery({ sha256 }).concat(createHttpLink()); execute(link, { query, variables }).subscribe(result => { expect(result.data).toEqual(data); const [, failure] = fetch.mock.calls[0]; @@ -230,6 +303,7 @@ describe('failure path', () => { done(); }, done.fail); }); + it('handles a 500 network error and still retries', done => { let failed = false; fetch.mockResponseOnce(response); @@ -249,7 +323,7 @@ describe('failure path', () => { return fetch(...args); }; - const link = createPersistedQuery().concat( + const link = createPersistedQuery({ sha256 }).concat( createHttpLink({ fetch: fetcher } as any), ); @@ -268,6 +342,7 @@ describe('failure path', () => { }, done.fail); }, done.fail); }); + it('handles a 400 network error and still retries', done => { let failed = false; fetch.mockResponseOnce(response); @@ -287,7 +362,7 @@ describe('failure path', () => { return fetch(...args); }; - const link = createPersistedQuery().concat( + const link = createPersistedQuery({ sha256 }).concat( createHttpLink({ fetch: fetcher } as any), ); @@ -317,7 +392,7 @@ describe('failure path', () => { status: 400, }); }; - const link = createPersistedQuery().concat( + const link = createPersistedQuery({ sha256 }).concat( createHttpLink({ fetch: fetcher } as any), ); diff --git a/src/link/persisted-queries/__tests__/react.tsx b/src/link/persisted-queries/__tests__/react.tsx index 787de91d58e..5c458cd59e1 100644 --- a/src/link/persisted-queries/__tests__/react.tsx +++ b/src/link/persisted-queries/__tests__/react.tsx @@ -52,7 +52,7 @@ describe('react application', () => { fetch.mockResponseOnce(response); fetch.mockResponseOnce(response2); - const link = createPersistedQuery().concat(createHttpLink()); + const link = createPersistedQuery({ sha256 }).concat(createHttpLink()); const client = new ApolloClient({ link, From 349296cd8eb770a9030fa693e8ad4a196973f81b Mon Sep 17 00:00:00 2001 From: hwillson <hugh@octonary.com> Date: Mon, 24 Aug 2020 08:11:59 -0400 Subject: [PATCH 08/15] Stop unnecessarily exporting `defaultOptions` --- src/__tests__/__snapshots__/exports.ts.snap | 1 - src/link/persisted-queries/index.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index b48d7db2459..b1cf1084965 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -178,7 +178,6 @@ exports[`exports of public entry points @apollo/client/link/persisted-queries 1` Array [ "VERSION", "createPersistedQueryLink", - "defaultOptions", ] `; diff --git a/src/link/persisted-queries/index.ts b/src/link/persisted-queries/index.ts index 573333580e0..ce3311bb712 100644 --- a/src/link/persisted-queries/index.ts +++ b/src/link/persisted-queries/index.ts @@ -41,7 +41,7 @@ namespace PersistedQueryLink { export type Options = SHA256Options | GenerateHashOptions; } -export const defaultOptions = { +const defaultOptions = { disable: ({ graphQLErrors, operation }: ErrorResponse) => { // if the server doesn't support persisted queries, don't try anymore if ( From e4a32efa5d6274aaa9ceec247c8a2cc1fa2ab935 Mon Sep 17 00:00:00 2001 From: hwillson <hugh@octonary.com> Date: Thu, 10 Sep 2020 13:46:29 -0400 Subject: [PATCH 09/15] Add link docs --- docs/gatsby-config.js | 3 +- docs/source/api/link/persisted-queries.md | 164 ++++++++++++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 docs/source/api/link/persisted-queries.md diff --git a/docs/gatsby-config.js b/docs/gatsby-config.js index 799a210f664..4932aa1e533 100644 --- a/docs/gatsby-config.js +++ b/docs/gatsby-config.js @@ -102,7 +102,8 @@ module.exports = { 'api/link/apollo-link-rest', 'api/link/apollo-link-retry', 'api/link/apollo-link-schema', - 'api/link/apollo-link-ws' + 'api/link/apollo-link-ws', + 'api/link/persisted-queries' ], }, }, diff --git a/docs/source/api/link/persisted-queries.md b/docs/source/api/link/persisted-queries.md new file mode 100644 index 00000000000..4ed8e3f4266 --- /dev/null +++ b/docs/source/api/link/persisted-queries.md @@ -0,0 +1,164 @@ +--- +title: Persisted Queries Link +description: Replace full queries with generated ID's to reduce bandwidth. +--- + +## Problem to solve + +Unlike REST APIs that use a fixed URL to load data, GraphQL provides a rich query language that can be used to express the shape of application data requirements. This is a marvelous advancement in technology, but it comes at a cost: GraphQL query strings are often much longer than REST URLS — in some cases by many kilobytes. + +In practice we've seen GraphQL query sizes ranging well above 10 KB *just for the query text*. This is significant overhead when compared with a simple URL of 50-100 characters. When paired with the fact that the uplink speed from the client is typically the most bandwidth-constrained part of the chain, large queries can become bottlenecks for client performance. + +Automatic Persisted Queries solves this problem by sending a generated ID instead of the query text as the request. + +For more information about this solution, read [this article announcing Automatic Persisted Queries](https://www.apollographql.com/blog/improve-graphql-performance-with-automatic-persisted-queries-c31d27b8e6ea/). + +## How it works + +1. When the client makes a query, it will optimistically send a short (64-byte) cryptographic hash instead of the full query text. +2. If the backend recognizes the hash, it will retrieve the full text of the query and execute it. +3. If the backend doesn't recognize the hash, it will ask the client to send the hash and the query text so it can store them mapped together for future lookups. During this request, the backend will also fulfill the data request. + +This library is a client implementation for use with Apollo Client by using custom Apollo Link. + +## Installation + +This link is included in the `@apollo/client` package: + +`npm install @apollo/client` + +If you do not already have a SHA-256 based hashing function available in your application, you will need to install one separately. For example: + +`npm install crypto-hash` + +This link doesn't include a SHA-256 hash function by default, to avoid forcing one as a dependency. Developers should pick the most appropriate SHA-256 function (sync or async) for their needs/environment. + +## Usage + +The persisted query link requires using the `HttpLink`. The easiest way to use them together is to `concat` them into a single link. + +```js +import { HttpLink, InMemoryCache, ApolloClient } from "@apollo/client"; +import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries"; +import { sha256 } from 'crypto-hash'; + +const httpLink = new HttpLink({ uri: "/graphql" }); +const persistedQueriesLink = createPersistedQueryLink({ sha256 }); +const client = new ApolloClient({ + cache: new InMemoryCache(), + link: persistedQueriesLink.concat(httpLink); +}); +``` + +Thats it! Now your client will start sending query signatures instead of the full text resulting in improved network performance! + +#### Options + +The `createPersistedQueryLink` function takes a configuration object: + +- `sha256`: a SHA-256 hashing function. Can be sync or async. Providing a SHA-256 hashing function is required, unless you're defining a fully custom hashing approach via `generateHash`. +- `generateHash`: an optional function that takes the query document and returns the hash. If provided this custom function will override the default hashing approach that uses the supplied `sha256` function. If not provided, the persisted queries link will use a fallback hashing approach leveraging the `sha256` function. +- `useGETForHashedQueries`: set to `true` to use the HTTP `GET` method when sending the hashed version of queries (but not for mutations). `GET` requests are not compatible with `@apollo/client/link/batch-http`. +> If you want to use `GET` for non-mutation queries whether or not they are hashed, pass `useGETForQueries: true` option to `HttpLink` instead. If you want to use `GET` for all requests, pass `fetchOptions: {method: 'GET'}` to `HttpLink`. +- `disable`: a function which takes an `ErrorResponse` (see below) and returns a boolean to disable any future persisted queries for that session. This defaults to disabling on `PersistedQueryNotSupported` or a 400 or 500 http error. + +**ErrorResponse** + +The argument that the optional `disable` function is given is an object with the following keys: + +- `operation`: The Operation that encountered an error (contains `query`, `variables`, `operationName`, and `context`). +- `response`: The Execution of the response (contains `data` and `errors` as well `extensions` if sent from the server). +- `graphQLErrors`: An array of errors from the GraphQL endpoint. +- `networkError`: Any error during the link execution or server response. + +*Note*: `networkError` is the value from the downlink's `error` callback. In most cases, `graphQLErrors` is the `errors` field of the result from the last `next` call. A `networkError` can contain additional fields, such as a GraphQL object in the case of a failing HTTP status code from `@apollo/link/http`. In this situation, `graphQLErrors` is an alias for `networkError.result.errors` if the property exists. + +## Apollo Studio + +Apollo Studio supports receiving and fulfilling Automatic Persisted Queries. Simply adding this link into your client app will improve your network response times when using Apollo Studio. + +### Protocol + +Automatic Persisted Queries are made up of three parts: the query signature, error responses, and the negotiation protocol. + +**Query Signature** + +The query signature for Automatic Persisted Queries is sent through the `extensions` field of a request from the client. This is a transport independent way to send extra information along with the operation. + +```js +{ + operationName: 'MyQuery', + variables: null, + extensions: { + persistedQuery: { + version: 1, + sha256Hash: hashOfQuery + } + } +} +``` + +When sending an Automatic Persisted Query, the client omits the `query` field normally present, and instead sends an extension field with a `persistedQuery` object as shown above. The hash algorithm defaults to a `sha256` hash of the query string. + +If the client needs to register the hash, the query signature will be the same but include the full query text like so: + +```js +{ + operationName: 'MyQuery', + variables: null, + query: `query MyQuery { id }`, + extensions: { + persistedQuery: { + version: 1, + sha256Hash: hashOfQuery + } + } +} +``` + +This should only happen once across all clients when a new query is introduced into your application. + +**Error Responses** + +When the initial query signature is received by a backend, if it is unable to find the hash previously stored, it will send back the following response signature: + +```js +{ + errors: [ + { message: 'PersistedQueryNotFound' } + ] +} +``` + +If the backend doesn't support Automatic Persisted Queries, or does not want to support it for that particular client, it can send back the following which will tell the client to stop trying to send hashes: + +``` +{ + errors: [ + { message: 'PersistedQueryNotSupported' } + ] +} +``` + +**Negotiation Protocol** + +In order to support Automatic Persisted Queries, the client and server must follow the negotiation steps as outlined here: + +*Happy Path* +1. Client sends query signature with no `query` field +2. Server looks up query based on hash, if found, it resolves the data +3. Client receives data and completes request + +*Missing hash path* +1. Client sends query signature with no `query` field +2. Server looks up query based on hash, none is found +3. Server responds with NotFound error response +4. Client sends both hash and query string to Server +5. Server fulfills response and saves query string + hash for future lookup +6. Client receives data and completes request + +### Build time generation + +If you want to avoid hashing in the browser, you can use a build script to include the hash as part of the request, then pass a function to retrieve that hash when the operation is run. This works well with projects like [GraphQL Persisted Document Loader](https://github.com/leoasis/graphql-persisted-document-loader) which uses webpack to generate hashes at build time. + +If you use the above loader, you can pass `{ generateHash: ({ documentId }) => documentId }` to the `createPersistedQueryLink` call. From 3e3f5e7da32e42b0babf2c41c2cc755c2ad7f2e6 Mon Sep 17 00:00:00 2001 From: Ben Newman <ben@apollographql.com> Date: Fri, 11 Sep 2020 13:41:45 -0400 Subject: [PATCH 10/15] Simplify createPersistedQueryLink options processing. --- src/link/persisted-queries/index.ts | 68 +++++++++++++---------------- 1 file changed, 31 insertions(+), 37 deletions(-) diff --git a/src/link/persisted-queries/index.ts b/src/link/persisted-queries/index.ts index ce3311bb712..c896ec70606 100644 --- a/src/link/persisted-queries/index.ts +++ b/src/link/persisted-queries/index.ts @@ -8,7 +8,7 @@ import { import { invariant } from 'ts-invariant'; import { ApolloLink, Operation } from '../core'; -import { Observable, Observer } from '../../utilities'; +import { Observable, Observer, compact } from '../../utilities'; export const VERSION = 1; @@ -78,26 +78,6 @@ function operationIsQuery(operation: Operation) { return !operation.query.definitions.some(definitionIsMutation); } -// Ensure a SHA-256 hash function is provided, if a custom hash generation -// function is not provided. We don't supply a SHA-256 hash function by -// default, to avoid forcing one as a dependency. Developers should pick the -// most appropriate SHA-256 function (sync or async) for their -// needs/environment, or provide a fully custom hash generation function -// (via the `generateHash` option) if they want to handle hashing with -// something other than SHA-256. -function verifyHashFunction( - sha256?: SHA256Function, - generateHash?: GenerateHashFunction -) { - invariant( - (sha256 && typeof sha256 === 'function') || - (generateHash && typeof generateHash === 'function'), - 'Missing/invalid "sha256" or "generateHash" function. Please ' + - 'configure one using the "createPersistedQueryLink(options)" options ' + - 'parameter. ' - ); -} - const { hasOwnProperty } = Object.prototype; const hashesKeyString = '__createPersistedQueryLink_hashes'; const hashesKey = @@ -107,23 +87,34 @@ let nextHashesChildKey = 0; export const createPersistedQueryLink = ( options: PersistedQueryLink.Options, ) => { + // Ensure a SHA-256 hash function is provided, if a custom hash + // generation function is not provided. We don't supply a SHA-256 hash + // function by default, to avoid forcing one as a dependency. Developers + // should pick the most appropriate SHA-256 function (sync or async) for + // their needs/environment, or provide a fully custom hash generation + // function (via the `generateHash` option) if they want to handle + // hashing with something other than SHA-256. + invariant( + options && ( + typeof options.sha256 === 'function' || + typeof options.generateHash === 'function' + ), + 'Missing/invalid "sha256" or "generateHash" function. Please ' + + 'configure one using the "createPersistedQueryLink(options)" options ' + + 'parameter.' + ); + const { sha256, - generateHash, + // If both a `sha256` and `generateHash` option are provided, the + // `sha256` option will be ignored. Developers can configure and + // use any hashing approach they want in a custom `generateHash` + // function; they aren't limited to SHA-256. + generateHash = (query: DocumentNode) => + Promise.resolve<string>(sha256!(print(query))), disable, useGETForHashedQueries - } = Object.assign({}, defaultOptions, options); - - verifyHashFunction(sha256, generateHash); - - // If both a `sha256` and `generateHash` option are provided, the - // `sha256` option will be ignored. Developers can configure and - // use any hashing approach they want in a custom `generateHash` - // function; they aren't limited to SHA-256. - const hash = - generateHash || - ((query: DocumentNode): Promise<string> => - Promise.resolve(sha256!(print(query)))); + } = compact(defaultOptions, options); let supportsPersistedQueries = true; @@ -133,7 +124,7 @@ export const createPersistedQueryLink = ( // If the query is not an object, we won't be able to store its hash as // a property of query[hashesKey], so we let generateHash(query) decide // what to do with the bogus query. - return hash(query); + return generateHash(query); } if (!hasOwnProperty.call(query, hashesKey)) { Object.defineProperty(query, hashesKey, { @@ -144,7 +135,7 @@ export const createPersistedQueryLink = ( const hashes = (query as any)[hashesKey]; return hasOwnProperty.call(hashes, hashesChildKey) ? hashes[hashesChildKey] - : (hashes[hashesChildKey] = hash(query)); + : (hashes[hashesChildKey] = generateHash(query)); } return new ApolloLink((operation, forward) => { @@ -239,7 +230,10 @@ export const createPersistedQueryLink = ( ({ fetchOptions = {} }: { fetchOptions: Record<string, any> }) => { originalFetchOptions = fetchOptions; return { - fetchOptions: Object.assign({}, fetchOptions, { method: 'GET' }), + fetchOptions: { + ...fetchOptions, + method: 'GET', + }, }; }, ); From ce4d70251de9c9a36ef0b4e10300390ff18f94db Mon Sep 17 00:00:00 2001 From: Ben Newman <ben@apollographql.com> Date: Fri, 11 Sep 2020 13:46:15 -0400 Subject: [PATCH 11/15] Allow GenerateHashFunction to return string | PromiseLike<string>. --- src/link/persisted-queries/index.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/link/persisted-queries/index.ts b/src/link/persisted-queries/index.ts index c896ec70606..2c85328fdc7 100644 --- a/src/link/persisted-queries/index.ts +++ b/src/link/persisted-queries/index.ts @@ -19,8 +19,8 @@ export interface ErrorResponse { operation: Operation; } -type SHA256Function = (...args: any[]) => string | Promise<string>; -type GenerateHashFunction = (document: DocumentNode) => Promise<string>; +type SHA256Function = (...args: any[]) => string | PromiseLike<string>; +type GenerateHashFunction = (document: DocumentNode) => string | PromiseLike<string>; namespace PersistedQueryLink { interface BaseOptions { @@ -119,12 +119,16 @@ export const createPersistedQueryLink = ( let supportsPersistedQueries = true; const hashesChildKey = 'forLink' + nextHashesChildKey++; + + const getHashPromise = (query: DocumentNode) => + new Promise<string>(resolve => resolve(generateHash(query))); + function getQueryHash(query: DocumentNode): Promise<string> { if (!query || typeof query !== 'object') { // If the query is not an object, we won't be able to store its hash as // a property of query[hashesKey], so we let generateHash(query) decide // what to do with the bogus query. - return generateHash(query); + return getHashPromise(query); } if (!hasOwnProperty.call(query, hashesKey)) { Object.defineProperty(query, hashesKey, { @@ -135,7 +139,7 @@ export const createPersistedQueryLink = ( const hashes = (query as any)[hashesKey]; return hasOwnProperty.call(hashes, hashesChildKey) ? hashes[hashesChildKey] - : (hashes[hashesChildKey] = generateHash(query)); + : hashes[hashesChildKey] = getHashPromise(query); } return new ApolloLink((operation, forward) => { From 68d3911f271bdf30ff4323f5cc2df4b2b94999f0 Mon Sep 17 00:00:00 2001 From: Ben Newman <ben@apollographql.com> Date: Fri, 11 Sep 2020 13:53:54 -0400 Subject: [PATCH 12/15] Use WeakMap to avoid adding Symbol properties to DocumentNode objects. --- src/link/persisted-queries/index.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/link/persisted-queries/index.ts b/src/link/persisted-queries/index.ts index 2c85328fdc7..9f17170a978 100644 --- a/src/link/persisted-queries/index.ts +++ b/src/link/persisted-queries/index.ts @@ -79,9 +79,12 @@ function operationIsQuery(operation: Operation) { } const { hasOwnProperty } = Object.prototype; -const hashesKeyString = '__createPersistedQueryLink_hashes'; -const hashesKey = - typeof Symbol === 'function' ? Symbol.for(hashesKeyString) : hashesKeyString; + +const hashesByQuery = new WeakMap< + DocumentNode, + Record<string, Promise<string>> +>(); + let nextHashesChildKey = 0; export const createPersistedQueryLink = ( @@ -130,13 +133,8 @@ export const createPersistedQueryLink = ( // what to do with the bogus query. return getHashPromise(query); } - if (!hasOwnProperty.call(query, hashesKey)) { - Object.defineProperty(query, hashesKey, { - value: Object.create(null), - enumerable: false, - }); - } - const hashes = (query as any)[hashesKey]; + let hashes = hashesByQuery.get(query)!; + if (!hashes) hashesByQuery.set(query, hashes = Object.create(null)); return hasOwnProperty.call(hashes, hashesChildKey) ? hashes[hashesChildKey] : hashes[hashesChildKey] = getHashPromise(query); From b3334757afcf91083fd3265287d8332b6836d201 Mon Sep 17 00:00:00 2001 From: Ben Newman <ben@apollographql.com> Date: Fri, 11 Sep 2020 13:58:12 -0400 Subject: [PATCH 13/15] Simplify confusingly-named operationIsQuery helper. --- src/link/persisted-queries/index.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/link/persisted-queries/index.ts b/src/link/persisted-queries/index.ts index 9f17170a978..362fa1e9935 100644 --- a/src/link/persisted-queries/index.ts +++ b/src/link/persisted-queries/index.ts @@ -1,6 +1,5 @@ import { print } from 'graphql/language/printer'; import { - DefinitionNode, DocumentNode, ExecutionResult, GraphQLError, @@ -69,13 +68,9 @@ const defaultOptions = { useGETForHashedQueries: false, }; -function definitionIsMutation(d: DefinitionNode) { - return d.kind === 'OperationDefinition' && d.operation === 'mutation'; -} - -// Note that this also returns true for subscriptions. -function operationIsQuery(operation: Operation) { - return !operation.query.definitions.some(definitionIsMutation); +function operationDefinesMutation(operation: Operation) { + return operation.query.definitions.some( + d => d.kind === 'OperationDefinition' && d.operation === 'mutation'); } const { hasOwnProperty } = Object.prototype; @@ -226,7 +221,7 @@ export const createPersistedQueryLink = ( if ( useGETForHashedQueries && supportsPersistedQueries && - operationIsQuery(operation) + !operationDefinesMutation(operation) ) { operation.setContext( ({ fetchOptions = {} }: { fetchOptions: Record<string, any> }) => { From 8ad97aa5c886127012a996dc1c9899ab928074da Mon Sep 17 00:00:00 2001 From: Ben Newman <ben@apollographql.com> Date: Fri, 11 Sep 2020 14:11:36 -0400 Subject: [PATCH 14/15] Support @apollo/client/link/persisted-queries in imports transform. --- codemods/ac2-to-ac3/examples/link-packages.js | 4 ++++ codemods/ac2-to-ac3/imports.js | 1 + 2 files changed, 5 insertions(+) diff --git a/codemods/ac2-to-ac3/examples/link-packages.js b/codemods/ac2-to-ac3/examples/link-packages.js index 5ad2211c91a..c8bd3f41ba8 100644 --- a/codemods/ac2-to-ac3/examples/link-packages.js +++ b/codemods/ac2-to-ac3/examples/link-packages.js @@ -4,6 +4,10 @@ import { BatchLink } from 'apollo-link-batch'; import { BatchHttpLink } from 'apollo-link-batch-http'; import { setContext } from 'apollo-link-context'; import { ErrorLink } from 'apollo-link-error'; +import { + VERSION, + createPersistedQueryLink, +} from 'apollo-link-persisted-queries'; import { RetryLink } from 'apollo-link-retry'; import { WebSocketLink } from 'apollo-link-ws'; // This package was unusual for having a default export. diff --git a/codemods/ac2-to-ac3/imports.js b/codemods/ac2-to-ac3/imports.js index 47435119534..af4fe7ea2a8 100644 --- a/codemods/ac2-to-ac3/imports.js +++ b/codemods/ac2-to-ac3/imports.js @@ -34,6 +34,7 @@ export default function transformer(file, api) { 'batch-http', 'context', 'error', + 'persisted-queries', 'retry', 'schema', 'ws', From 701eb3bb6cc6db1cf13b7c9992f6492a2b4feb5e Mon Sep 17 00:00:00 2001 From: Ben Newman <ben@apollographql.com> Date: Fri, 11 Sep 2020 14:09:24 -0400 Subject: [PATCH 15/15] Mention PR #6837 in CHANGELOG.md. --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3a284bfbaa..6396331ce7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,9 @@ - Allow `cache.modify` functions to return `details.INVALIDATE` (similar to `details.DELETE`) to invalidate the current field, causing affected queries to rerun, even if the field's value is unchanged. <br/> [@benjamn](https://github.com/benjamn) in [#6991](https://github.com/apollographql/apollo-client/pull/6991) +- Move `apollo-link-persisted-queries` implementation to `@apollo/client/link/persisted-queries`. Try running our [automated imports transform](https://github.com/apollographql/apollo-client/tree/main/codemods/ac2-to-ac3) to handle this conversion, if you're using `apollo-link-persisted-queries`. <br/> + [@hwillson](https://github.com/hwillson) in [#6837](https://github.com/apollographql/apollo-client/pull/6837) + - Remove invariant forbidding a `FetchPolicy` of `cache-only` in `ObservableQuery#refetch`. <br/> [@benjamn](https://github.com/benjamn) in [ccb0a79a](https://github.com/apollographql/apollo-client/pull/6774/commits/ccb0a79a588721f08bf87a131c31bf37fa3238e5), fixing [#6702](https://github.com/apollographql/apollo-client/issues/6702)