diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e7b7c0b0f5..2a9362e735a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ - Eliminate "generated" cache IDs to avoid normalizing objects with no meaningful ID, significantly reducing cache memory usage.
[@benjamn](https://github.com/benjamn) in [#5146](https://github.com/apollographql/apollo-client/pull/5146) +- Apollo Link core and HTTP related functionality has been merged into `@apollo/client`. Functionality that was previously available through the `apollo-link`, `apollo-link-http-common` and `apollo-link-http` packages is now directly available from `@apollo/client` (e.g. `import { HttpLink } from '@apollo/client'`). The `ApolloClient` constructor has also been updated to accept new `uri`, `headers` and `credentials` options. If `uri` is specified, Apollo Client will take care of creating the necessary `HttpLink` behind the scenes.
+ [@hwillson](https://github.com/hwillson) in [#5412](https://github.com/apollographql/apollo-client/pull/5412) + ### Breaking Changes - Removed `graphql-anywhere` since it's no longer used by Apollo Client.
diff --git a/config/rollup.config.js b/config/rollup.config.js index c654c0a51b7..68b3ad8d549 100644 --- a/config/rollup.config.js +++ b/config/rollup.config.js @@ -8,17 +8,19 @@ import packageJson from '../package.json'; const distDir = './dist'; const globals = { - 'apollo-link': 'apolloLink.core', 'tslib': 'tslib', 'ts-invariant': 'invariant', 'symbol-observable': '$$observable', 'graphql/language/printer': 'print', optimism: 'optimism', 'graphql/language/visitor': 'visitor', + 'graphql/language/printer': 'printer', + 'graphql/execution/execute': 'execute', 'fast-json-stable-stringify': 'stringify', '@wry/equality': 'wryEquality', graphql: 'graphql', - react: 'React' + react: 'React', + 'zen-observable': 'Observable' }; const hasOwn = Object.prototype.hasOwnProperty; diff --git a/package-lock.json b/package-lock.json index 181fff3adb5..86b72616159 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1156,12 +1156,6 @@ "integrity": "sha512-mky/O83TXmGY39P1H9YbUpjV6l6voRYlufqfFCvel8l1phuy8HRjdWc1rrPuN53ITBJlbyMSV6z3niOySO5pgQ==", "dev": true }, - "@types/isomorphic-fetch": { - "version": "0.0.35", - "resolved": "https://registry.npmjs.org/@types/isomorphic-fetch/-/isomorphic-fetch-0.0.35.tgz", - "integrity": "sha512-DaZNUvLDCAnCTjgwxgiL1eQdxIKEpNLOlTNtAgnZc50bG2copGhRrFN9/PxPBuJe+tZVLCbQ7ls0xveXVRPkvw==", - "dev": true - }, "@types/istanbul-lib-coverage": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", @@ -1688,30 +1682,6 @@ } } }, - "apollo-link": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/apollo-link/-/apollo-link-1.2.13.tgz", - "integrity": "sha512-+iBMcYeevMm1JpYgwDEIDt/y0BB7VWyvlm/7x+TIPNLHCTCMgcEgDuW5kH86iQZWo0I7mNwQiTOz+/3ShPFmBw==", - "requires": { - "apollo-utilities": "^1.3.0", - "ts-invariant": "^0.4.0", - "tslib": "^1.9.3", - "zen-observable-ts": "^0.8.20" - }, - "dependencies": { - "apollo-utilities": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/apollo-utilities/-/apollo-utilities-1.3.2.tgz", - "integrity": "sha512-JWNHj8XChz7S4OZghV6yc9FNnzEXj285QYp/nLNh943iObycI5GTDO3NGR9Dth12LRrSFMeDOConPfPln+WGfg==", - "requires": { - "@wry/equality": "^0.1.2", - "fast-json-stable-stringify": "^2.0.0", - "ts-invariant": "^0.4.0", - "tslib": "^1.9.3" - } - } - } - }, "aproba": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", @@ -2669,15 +2639,6 @@ "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", "dev": true }, - "encoding": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", - "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=", - "dev": true, - "requires": { - "iconv-lite": "~0.4.13" - } - }, "end-of-stream": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.1.tgz", @@ -3047,28 +3008,28 @@ "dependencies": { "abbrev": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "resolved": false, "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true, "optional": true }, "ansi-regex": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "resolved": false, "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", "dev": true, "optional": true }, "aproba": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "resolved": false, "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", "dev": true, "optional": true }, "are-we-there-yet": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", + "resolved": false, "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", "dev": true, "optional": true, @@ -3079,14 +3040,14 @@ }, "balanced-match": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "resolved": false, "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true, "optional": true }, "brace-expansion": { "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "resolved": false, "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "dev": true, "optional": true, @@ -3097,42 +3058,42 @@ }, "chownr": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.1.tgz", + "resolved": false, "integrity": "sha512-j38EvO5+LHX84jlo6h4UzmOwi0UgW61WRyPtJz4qaadK5eY3BTS5TY/S1Stc3Uk2lIM6TPevAlULiEJwie860g==", "dev": true, "optional": true }, "code-point-at": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "resolved": false, "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", "dev": true, "optional": true }, "concat-map": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "resolved": false, "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true, "optional": true }, "console-control-strings": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "resolved": false, "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", "dev": true, "optional": true }, "core-util-is": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "resolved": false, "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", "dev": true, "optional": true }, "debug": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "resolved": false, "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", "dev": true, "optional": true, @@ -3142,28 +3103,28 @@ }, "deep-extend": { "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "resolved": false, "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", "dev": true, "optional": true }, "delegates": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "resolved": false, "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", "dev": true, "optional": true }, "detect-libc": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "resolved": false, "integrity": "sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=", "dev": true, "optional": true }, "fs-minipass": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz", + "resolved": false, "integrity": "sha512-JhBl0skXjUPCFH7x6x61gQxrKyXsxB5gcgePLZCwfyCGGsTISMoIeObbrvVeP6Xmyaudw4TT43qV2Gz+iyd2oQ==", "dev": true, "optional": true, @@ -3173,14 +3134,14 @@ }, "fs.realpath": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "resolved": false, "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", "dev": true, "optional": true }, "gauge": { "version": "2.7.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "resolved": false, "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", "dev": true, "optional": true, @@ -3197,7 +3158,7 @@ }, "glob": { "version": "7.1.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "resolved": false, "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", "dev": true, "optional": true, @@ -3212,14 +3173,14 @@ }, "has-unicode": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "resolved": false, "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", "dev": true, "optional": true }, "iconv-lite": { "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "resolved": false, "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", "dev": true, "optional": true, @@ -3229,7 +3190,7 @@ }, "ignore-walk": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.1.tgz", + "resolved": false, "integrity": "sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ==", "dev": true, "optional": true, @@ -3239,7 +3200,7 @@ }, "inflight": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "resolved": false, "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", "dev": true, "optional": true, @@ -3250,21 +3211,21 @@ }, "inherits": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "resolved": false, "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", "dev": true, "optional": true }, "ini": { "version": "1.3.5", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "resolved": false, "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", "dev": true, "optional": true }, "is-fullwidth-code-point": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "resolved": false, "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", "dev": true, "optional": true, @@ -3274,14 +3235,14 @@ }, "isarray": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "resolved": false, "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", "dev": true, "optional": true }, "minimatch": { "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "resolved": false, "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", "dev": true, "optional": true, @@ -3291,14 +3252,14 @@ }, "minimist": { "version": "0.0.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "resolved": false, "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", "dev": true, "optional": true }, "minipass": { "version": "2.3.5", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz", + "resolved": false, "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==", "dev": true, "optional": true, @@ -3309,7 +3270,7 @@ }, "minizlib": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.2.1.tgz", + "resolved": false, "integrity": "sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA==", "dev": true, "optional": true, @@ -3319,7 +3280,7 @@ }, "mkdirp": { "version": "0.5.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "resolved": false, "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", "dev": true, "optional": true, @@ -3329,14 +3290,14 @@ }, "ms": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "resolved": false, "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", "dev": true, "optional": true }, "needle": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/needle/-/needle-2.3.0.tgz", + "resolved": false, "integrity": "sha512-QBZu7aAFR0522EyaXZM0FZ9GLpq6lvQ3uq8gteiDUp7wKdy0lSd2hPlgFwVuW1CBkfEs9PfDQsQzZghLs/psdg==", "dev": true, "optional": true, @@ -3348,7 +3309,7 @@ }, "node-pre-gyp": { "version": "0.12.0", - "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz", + "resolved": false, "integrity": "sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A==", "dev": true, "optional": true, @@ -3367,7 +3328,7 @@ }, "nopt": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-4.0.1.tgz", + "resolved": false, "integrity": "sha1-0NRoWv1UFRk8jHUFYC0NF81kR00=", "dev": true, "optional": true, @@ -3378,14 +3339,14 @@ }, "npm-bundled": { "version": "1.0.6", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.0.6.tgz", + "resolved": false, "integrity": "sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g==", "dev": true, "optional": true }, "npm-packlist": { "version": "1.4.1", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-1.4.1.tgz", + "resolved": false, "integrity": "sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw==", "dev": true, "optional": true, @@ -3396,7 +3357,7 @@ }, "npmlog": { "version": "4.1.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "resolved": false, "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", "dev": true, "optional": true, @@ -3409,21 +3370,21 @@ }, "number-is-nan": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "resolved": false, "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", "dev": true, "optional": true }, "object-assign": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "resolved": false, "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", "dev": true, "optional": true }, "once": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "resolved": false, "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", "dev": true, "optional": true, @@ -3433,21 +3394,21 @@ }, "os-homedir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "resolved": false, "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "dev": true, "optional": true }, "os-tmpdir": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "resolved": false, "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", "dev": true, "optional": true }, "osenv": { "version": "0.1.5", - "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "resolved": false, "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", "dev": true, "optional": true, @@ -3458,21 +3419,21 @@ }, "path-is-absolute": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "resolved": false, "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", "dev": true, "optional": true }, "process-nextick-args": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "resolved": false, "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", "dev": true, "optional": true }, "rc": { "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "resolved": false, "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", "dev": true, "optional": true, @@ -3485,7 +3446,7 @@ "dependencies": { "minimist": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "resolved": false, "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", "dev": true, "optional": true @@ -3494,7 +3455,7 @@ }, "readable-stream": { "version": "2.3.6", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "resolved": false, "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", "dev": true, "optional": true, @@ -3510,7 +3471,7 @@ }, "rimraf": { "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "resolved": false, "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "dev": true, "optional": true, @@ -3520,49 +3481,49 @@ }, "safe-buffer": { "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "resolved": false, "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, "optional": true }, "safer-buffer": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "resolved": false, "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true, "optional": true }, "sax": { "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "resolved": false, "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", "dev": true, "optional": true }, "semver": { "version": "5.7.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "resolved": false, "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", "dev": true, "optional": true }, "set-blocking": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "resolved": false, "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", "dev": true, "optional": true }, "signal-exit": { "version": "3.0.2", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "resolved": false, "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true, "optional": true }, "string-width": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "resolved": false, "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", "dev": true, "optional": true, @@ -3574,7 +3535,7 @@ }, "string_decoder": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "resolved": false, "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "optional": true, @@ -3584,7 +3545,7 @@ }, "strip-ansi": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "resolved": false, "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", "dev": true, "optional": true, @@ -3594,14 +3555,14 @@ }, "strip-json-comments": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "resolved": false, "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", "dev": true, "optional": true }, "tar": { "version": "4.4.8", - "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.8.tgz", + "resolved": false, "integrity": "sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ==", "dev": true, "optional": true, @@ -3617,14 +3578,14 @@ }, "util-deprecate": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "resolved": false, "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", "dev": true, "optional": true }, "wide-align": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "resolved": false, "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", "dev": true, "optional": true, @@ -3634,14 +3595,14 @@ }, "wrappy": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "resolved": false, "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", "dev": true, "optional": true }, "yallist": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", + "resolved": false, "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", "dev": true, "optional": true @@ -3742,9 +3703,9 @@ "dev": true }, "graphql": { - "version": "14.5.4", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-14.5.4.tgz", - "integrity": "sha512-dPLvHoxy5m9FrkqWczPPRnH0X80CyvRE6e7Fa5AWEqEAzg9LpxHvKh24po/482E6VWHigOkAmb4xCp6P9yT9gw==", + "version": "14.5.8", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-14.5.8.tgz", + "integrity": "sha512-MMwmi0zlVLQKLdGiMfWkgQD7dY/TUKt4L+zgJ/aR0Howebod3aNgP5JkgvAULiR2HPVZaP2VEElqtdidHweLkg==", "dev": true, "requires": { "iterall": "^1.2.2" @@ -4187,28 +4148,6 @@ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, - "isomorphic-fetch": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", - "integrity": "sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk=", - "dev": true, - "requires": { - "node-fetch": "^1.0.1", - "whatwg-fetch": ">=0.10.0" - }, - "dependencies": { - "node-fetch": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", - "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", - "dev": true, - "requires": { - "encoding": "^0.1.11", - "is-stream": "^1.0.1" - } - } - } - }, "isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", @@ -8666,12 +8605,6 @@ "iconv-lite": "0.4.24" } }, - "whatwg-fetch": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz", - "integrity": "sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q==", - "dev": true - }, "whatwg-mimetype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", @@ -8773,15 +8706,6 @@ "version": "0.8.14", "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.14.tgz", "integrity": "sha512-kQz39uonEjEESwh+qCi83kcC3rZJGh4mrZW7xjkSQYXkq//JZHTtKo+6yuVloTgMtzsIWOJrjIrKvk/dqm0L5g==" - }, - "zen-observable-ts": { - "version": "0.8.20", - "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-0.8.20.tgz", - "integrity": "sha512-2rkjiPALhOtRaDX6pWyNqK1fnP5KkJJybYebopNSn6wDG1lxBoFs2+nwwXKoA6glHIrtwrfBBy6da0stkKtTAA==", - "requires": { - "tslib": "^1.9.3", - "zen-observable": "^0.8.0" - } } } } diff --git a/package.json b/package.json index bb360b46db6..9b66550f5d6 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ { "name": "apollo-client", "path": "./dist/apollo-client.cjs.min.js", - "maxSize": "21 kB" + "maxSize": "24 kB" } ], "peerDependencies": { @@ -59,7 +59,6 @@ "dependencies": { "@types/zen-observable": "^0.8.0", "@wry/equality": "^0.1.9", - "apollo-link": "^1.2.13", "fast-json-stable-stringify": "^2.0.0", "optimism": "^0.11.3", "symbol-observable": "^1.2.0", @@ -70,7 +69,6 @@ "devDependencies": { "@testing-library/react": "^9.1.4", "@types/fast-json-stable-stringify": "^2.0.0", - "@types/isomorphic-fetch": "0.0.35", "@types/jest": "24.0.18", "@types/lodash": "4.14.139", "@types/node": "12.7.5", @@ -78,10 +76,9 @@ "@types/react-dom": "16.9.0", "bundlesize": "0.18.0", "codecov": "3.5.0", - "fetch-mock": "7.3.9", - "graphql": "14.5.4", + "fetch-mock": "^7.3.9", + "graphql": "^14.5.8", "graphql-tag": "2.10.1", - "isomorphic-fetch": "2.2.1", "jest": "24.9.0", "jest-junit": "8.0.0", "lodash": "4.17.15", diff --git a/src/ApolloClient.ts b/src/ApolloClient.ts index 1b1b1f51b23..ee6dc79658a 100644 --- a/src/ApolloClient.ts +++ b/src/ApolloClient.ts @@ -1,12 +1,12 @@ +import { ExecutionResult, DocumentNode } from 'graphql'; +import { invariant, InvariantError } from 'ts-invariant'; + import { ApolloLink, FetchResult, GraphQLRequest, execute, -} from 'apollo-link'; -import { ExecutionResult, DocumentNode } from 'graphql'; -import { invariant, InvariantError } from 'ts-invariant'; - +} from './link/core'; import { ApolloCache, DataProxy } from './cache/core'; import { QueryManager } from './core/QueryManager'; import { @@ -26,6 +26,7 @@ import { } from './core/watchQueryOptions'; import { DataStore } from './data/store'; import { version } from './version'; +import { UriFunction, HttpLink } from './link/http'; export interface DefaultOptions { watchQuery?: Partial; @@ -36,6 +37,9 @@ export interface DefaultOptions { let hasSuggestedDevtools = false; export type ApolloClientOptions = { + uri?: string | UriFunction; + credentials?: string; + headers?: Record; link?: ApolloLink; cache: ApolloCache; ssrForceFetchDelay?: number; @@ -76,6 +80,8 @@ export default class ApolloClient implements DataProxy { /** * Constructs an instance of {@link ApolloClient}. * + * @param uri The GraphQL endpoint that Apollo Client will connect to. If + * `link` is configured, this option is ignored. * @param link The {@link ApolloLink} over which GraphQL documents will be resolved into a response. * * @param cache The initial cache to use in the data store. @@ -109,6 +115,9 @@ export default class ApolloClient implements DataProxy { */ constructor(options: ApolloClientOptions) { const { + uri, + credentials, + headers, cache, ssrMode = false, ssrForceFetchDelay = 0, @@ -125,21 +134,23 @@ export default class ApolloClient implements DataProxy { let { link } = options; - // If a link hasn't been defined, but local state resolvers have been set, - // setup a default empty link. - if (!link && resolvers) { - link = ApolloLink.empty(); + if (!link) { + if (uri) { + link = new HttpLink({ uri, credentials, headers }); + } else if (resolvers) { + link = ApolloLink.empty(); + } } if (!link || !cache) { throw new InvariantError( - "In order to initialize Apollo Client, you must specify 'link' and 'cache' properties in the options object.\n" + - "These options are part of the upgrade requirements when migrating from Apollo Client 1.x to Apollo Client 2.x.\n" + - "For more information, please visit: https://www.apollographql.com/docs/tutorial/client.html#apollo-client-setup" + "To initialize Apollo Client, you must specify 'uri' or 'link' and " + + "'cache' properties in the options object. \n" + + "For more information, please visit: " + + "https://www.apollographql.com/docs/react/" ); } - // remove apollo-client supported directives this.link = link; this.cache = cache; this.store = new DataStore(cache); diff --git a/src/__mocks__/mockLinks.ts b/src/__mocks__/mockLinks.ts index 3002350a9d2..46a05a82537 100644 --- a/src/__mocks__/mockLinks.ts +++ b/src/__mocks__/mockLinks.ts @@ -1,12 +1,12 @@ +import { print } from 'graphql/language/printer'; + +import { Observable } from '../util/Observable'; import { Operation, ApolloLink, FetchResult, - Observable, GraphQLRequest, -} from 'apollo-link'; - -import { print } from 'graphql/language/printer'; +} from '../link/core'; interface MockApolloLink extends ApolloLink { operation?: Operation; diff --git a/src/__tests__/ApolloClient.ts b/src/__tests__/ApolloClient.ts index 44600c164b9..34db3f60b5d 100644 --- a/src/__tests__/ApolloClient.ts +++ b/src/__tests__/ApolloClient.ts @@ -1,6 +1,8 @@ import gql from 'graphql-tag'; -import { ApolloLink, Observable } from 'apollo-link'; +import { Observable } from '../util/Observable'; +import { ApolloLink } from '../link/core'; +import { HttpLink } from '../link/http'; import { InMemoryCache, makeReference } from '../cache/inmemory'; import { stripSymbols } from '../utilities'; import { withWarning } from '../util/wrap'; @@ -10,7 +12,18 @@ import { FetchPolicy, QueryOptions } from '../core/watchQueryOptions'; describe('ApolloClient', () => { describe('constructor', () => { - it('will throw an error if link is not passed in', () => { + let oldFetch: any; + + beforeEach(() => { + oldFetch = window.fetch; + window.fetch = () => null; + }) + + afterEach(() => { + window.fetch = oldFetch; + }); + + it('will throw an error if `uri` or `link` is not passed in', () => { expect(() => { new ApolloClient({ cache: new InMemoryCache() } as any); }).toThrowErrorMatchingSnapshot(); @@ -21,6 +34,28 @@ describe('ApolloClient', () => { new ApolloClient({ link: ApolloLink.empty() } as any); }).toThrowErrorMatchingSnapshot(); }); + + it('should create an `HttpLink` instance if `uri` is provided', () => { + const uri = 'http://localhost:4000'; + const client = new ApolloClient({ + cache: new InMemoryCache(), + uri, + }); + + expect(client.link).toBeDefined(); + expect((client.link as HttpLink).options.uri).toEqual(uri); + }); + + it('should accept `link` over `uri` if both are provided', () => { + const uri1 = 'http://localhost:3000'; + const uri2 = 'http://localhost:4000'; + const client = new ApolloClient({ + cache: new InMemoryCache(), + uri: uri1, + link: new HttpLink({ uri: uri2 }) + }); + expect((client.link as HttpLink).options.uri).toEqual(uri2); + }); }); describe('readQuery', () => { diff --git a/src/__tests__/__snapshots__/ApolloClient.ts.snap b/src/__tests__/__snapshots__/ApolloClient.ts.snap index 08e5848b250..ab1a665922f 100644 --- a/src/__tests__/__snapshots__/ApolloClient.ts.snap +++ b/src/__tests__/__snapshots__/ApolloClient.ts.snap @@ -1,15 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ApolloClient constructor will throw an error if cache is not passed in 1`] = ` -"In order to initialize Apollo Client, you must specify 'link' and 'cache' properties in the options object. -These options are part of the upgrade requirements when migrating from Apollo Client 1.x to Apollo Client 2.x. -For more information, please visit: https://www.apollographql.com/docs/tutorial/client.html#apollo-client-setup" +exports[`ApolloClient constructor will throw an error if \`uri\` or \`link\` is not passed in 1`] = ` +"To initialize Apollo Client, you must specify 'uri' or 'link' and 'cache' properties in the options object. +For more information, please visit: https://www.apollographql.com/docs/react/" `; -exports[`ApolloClient constructor will throw an error if link is not passed in 1`] = ` -"In order to initialize Apollo Client, you must specify 'link' and 'cache' properties in the options object. -These options are part of the upgrade requirements when migrating from Apollo Client 1.x to Apollo Client 2.x. -For more information, please visit: https://www.apollographql.com/docs/tutorial/client.html#apollo-client-setup" +exports[`ApolloClient constructor will throw an error if cache is not passed in 1`] = ` +"To initialize Apollo Client, you must specify 'uri' or 'link' and 'cache' properties in the options object. +For more information, please visit: https://www.apollographql.com/docs/react/" `; exports[`ApolloClient write then read will not use a default id getter if either _id or id is present when __typename is not also present 1`] = ` diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 46088d75a25..7f3f2067bec 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -1,8 +1,9 @@ import { cloneDeep, assign } from 'lodash'; import { GraphQLError, ExecutionResult, DocumentNode } from 'graphql'; import gql from 'graphql-tag'; -import { ApolloLink, Observable } from 'apollo-link'; +import { Observable } from '../util/Observable'; +import { ApolloLink } from '../link/core'; import { InMemoryCache, PossibleTypesMap } from '../cache/inmemory'; import { stripSymbols } from '../utilities'; import { WatchQueryOptions, FetchPolicy } from '../core/watchQueryOptions'; diff --git a/src/__tests__/local-state/export.ts b/src/__tests__/local-state/export.ts index 58550b0cfff..3ac26031a3a 100644 --- a/src/__tests__/local-state/export.ts +++ b/src/__tests__/local-state/export.ts @@ -1,7 +1,8 @@ import gql from 'graphql-tag'; -import { ApolloLink, Observable } from 'apollo-link'; import { print } from 'graphql/language/printer'; +import { Observable } from '../../util/Observable'; +import { ApolloLink } from '../../link/core'; import ApolloClient from '../..'; import { InMemoryCache } from '../../cache/inmemory'; diff --git a/src/__tests__/local-state/general.ts b/src/__tests__/local-state/general.ts index 98471f8c21f..0479dc46f78 100644 --- a/src/__tests__/local-state/general.ts +++ b/src/__tests__/local-state/general.ts @@ -1,8 +1,9 @@ import gql from 'graphql-tag'; import { DocumentNode, GraphQLError } from 'graphql'; import { introspectionQuery } from 'graphql/utilities'; -import { ApolloLink, Observable, Operation } from 'apollo-link'; +import { Observable } from '../../util/Observable'; +import { ApolloLink, Operation } from '../../link/core'; import ApolloClient from '../..'; import { ApolloCache } from '../../cache/core'; import { InMemoryCache } from '../../cache/inmemory'; diff --git a/src/__tests__/local-state/resolvers.ts b/src/__tests__/local-state/resolvers.ts index 4c339cac722..85d0a84ade3 100644 --- a/src/__tests__/local-state/resolvers.ts +++ b/src/__tests__/local-state/resolvers.ts @@ -1,8 +1,9 @@ import gql from 'graphql-tag'; import { DocumentNode, ExecutionResult } from 'graphql'; import { assign } from 'lodash'; -import { ApolloLink, Observable } from 'apollo-link'; +import { Observable } from '../../util/Observable'; +import { ApolloLink } from '../../link/core'; import ApolloClient from '../..'; import mockQueryManager from '../../__mocks__/mockQueryManager'; import { Observer } from '../../util/Observable'; diff --git a/src/__tests__/local-state/subscriptions.ts b/src/__tests__/local-state/subscriptions.ts index 72339a38d4f..b13c9982928 100644 --- a/src/__tests__/local-state/subscriptions.ts +++ b/src/__tests__/local-state/subscriptions.ts @@ -1,6 +1,7 @@ import gql from 'graphql-tag'; -import { ApolloLink, Observable } from 'apollo-link'; +import { Observable } from '../../util/Observable'; +import { ApolloLink } from '../../link/core'; import ApolloClient from '../..'; import { InMemoryCache } from '../../cache/inmemory'; diff --git a/src/__tests__/mutationResults.ts b/src/__tests__/mutationResults.ts index 89f6e28c819..f41ec01f68a 100644 --- a/src/__tests__/mutationResults.ts +++ b/src/__tests__/mutationResults.ts @@ -1,7 +1,8 @@ -import { ApolloLink, Observable } from 'apollo-link'; import { cloneDeep } from 'lodash'; import gql from 'graphql-tag'; +import { Observable } from '../util/Observable'; +import { ApolloLink } from '../link/core'; import { mockSingleLink } from '../__mocks__/mockLinks'; import ApolloClient from '..'; import { InMemoryCache } from '../cache/inmemory'; diff --git a/src/__tests__/subscribeToMore.ts b/src/__tests__/subscribeToMore.ts index 0ddaa19433f..74a61fd5f1e 100644 --- a/src/__tests__/subscribeToMore.ts +++ b/src/__tests__/subscribeToMore.ts @@ -1,7 +1,7 @@ import gql from 'graphql-tag'; -import { ApolloLink, Operation } from 'apollo-link'; - import { DocumentNode, OperationDefinitionNode } from 'graphql'; + +import { ApolloLink, Operation } from '../link/core'; import { mockSingleLink, mockObservableLink } from '../__mocks__/mockLinks'; import ApolloClient from '../'; import { InMemoryCache } from '../cache/inmemory'; diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index 2ea65037193..ecd1c0437e7 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -1,7 +1,7 @@ -import { execute, ApolloLink, FetchResult } from 'apollo-link'; import { ExecutionResult, DocumentNode } from 'graphql'; import { invariant, InvariantError } from 'ts-invariant'; +import { execute, ApolloLink, FetchResult } from '../link/core'; import { Cache } from '../cache/core'; import { getDefaultValues, diff --git a/src/core/__tests__/ObservableQuery.ts b/src/core/__tests__/ObservableQuery.ts index 80ca834091e..f466b8ac6c2 100644 --- a/src/core/__tests__/ObservableQuery.ts +++ b/src/core/__tests__/ObservableQuery.ts @@ -1,8 +1,9 @@ import gql from 'graphql-tag'; -import { ApolloLink, Observable } from 'apollo-link'; -import { InMemoryCache } from '../../cache/inmemory'; import { GraphQLError } from 'graphql'; +import { Observable } from '../../util/Observable'; +import { ApolloLink } from '../../link/core'; +import { InMemoryCache } from '../../cache/inmemory'; import mockQueryManager from '../../__mocks__/mockQueryManager'; import mockWatchQuery from '../../__mocks__/mockWatchQuery'; import { mockSingleLink } from '../../__mocks__/mockLinks'; diff --git a/src/core/__tests__/QueryManager/index.ts b/src/core/__tests__/QueryManager/index.ts index 4211e20fc76..44f09941289 100644 --- a/src/core/__tests__/QueryManager/index.ts +++ b/src/core/__tests__/QueryManager/index.ts @@ -4,8 +4,9 @@ import { map } from 'rxjs/operators'; import { assign } from 'lodash'; import gql from 'graphql-tag'; import { DocumentNode, ExecutionResult, GraphQLError } from 'graphql'; -import { ApolloLink, Operation, Observable } from 'apollo-link'; +import { Observable } from '../../../util/Observable'; +import { ApolloLink, Operation } from '../../../link/core'; import { InMemoryCache, ApolloReducerConfig, diff --git a/src/core/__tests__/QueryManager/links.ts b/src/core/__tests__/QueryManager/links.ts index 2b283f787ea..465598641ab 100644 --- a/src/core/__tests__/QueryManager/links.ts +++ b/src/core/__tests__/QueryManager/links.ts @@ -1,6 +1,8 @@ // externals import gql from 'graphql-tag'; -import { ApolloLink, Observable } from 'apollo-link'; + +import { Observable } from '../../../util/Observable'; +import { ApolloLink } from '../../../link/core'; import { InMemoryCache } from '../../../cache/inmemory'; import { stripSymbols } from '../../../utilities'; diff --git a/src/core/__tests__/fetchPolicies.ts b/src/core/__tests__/fetchPolicies.ts index 2373c578fab..dcd77a2ac51 100644 --- a/src/core/__tests__/fetchPolicies.ts +++ b/src/core/__tests__/fetchPolicies.ts @@ -1,6 +1,6 @@ import gql from 'graphql-tag'; -import { ApolloLink } from 'apollo-link'; +import { ApolloLink } from '../../link/core'; import { InMemoryCache } from '../../cache/inmemory'; import { stripSymbols } from '../../utilities'; import ApolloClient from '../..'; diff --git a/src/core/types.ts b/src/core/types.ts index caafd37cf85..6c18fe9f51a 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,6 +1,6 @@ -import { FetchResult } from 'apollo-link'; import { DocumentNode, GraphQLError } from 'graphql'; +import { FetchResult } from '../link/core'; import { QueryStoreValue } from '../data/queries'; import { NetworkStatus } from './networkStatus'; import { Resolver } from './LocalState'; diff --git a/src/core/watchQueryOptions.ts b/src/core/watchQueryOptions.ts index 07cdf185156..5cc5762297a 100644 --- a/src/core/watchQueryOptions.ts +++ b/src/core/watchQueryOptions.ts @@ -1,6 +1,6 @@ import { DocumentNode, ExecutionResult } from 'graphql'; -import { FetchResult } from 'apollo-link'; +import { FetchResult } from '../link/core'; import { DataProxy } from '../cache/core'; import { MutationQueryReducersMap } from './types'; import { PureQueryOptions, OperationVariables } from './types'; diff --git a/src/index.ts b/src/index.ts index 44ae88c0c40..6934d778aa8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export { UpdateQueryOptions, ApolloCurrentQueryResult, } from './core/ObservableQuery'; + export { QueryBaseOptions, QueryOptions, @@ -17,8 +18,11 @@ export { SubscribeToMoreOptions, MutationUpdaterFn, } from './core/watchQueryOptions'; + export { NetworkStatus } from './core/networkStatus'; + export * from './core/types'; + export { Resolver, FragmentMatcher as LocalStateFragmentMatcher, @@ -40,3 +44,11 @@ export * from './cache/core'; export * from './cache/inmemory'; export * from './react'; + +export * from './link/core'; +export * from './link/http'; +export * from './link/utils'; + +export { Observable } from './util/Observable'; + +export * from './utilities'; diff --git a/src/link/core/ApolloLink.ts b/src/link/core/ApolloLink.ts new file mode 100644 index 00000000000..dfa8debc758 --- /dev/null +++ b/src/link/core/ApolloLink.ts @@ -0,0 +1,141 @@ +import { InvariantError, invariant } from 'ts-invariant'; + +import { Observable } from '../../util/Observable'; +import { + NextLink, + Operation, + RequestHandler, + FetchResult, + GraphQLRequest +} from './types'; +import { validateOperation } from '../utils/validateOperation'; +import { createOperation } from '../utils/createOperation'; +import { transformOperation } from '../utils/transformOperation'; + +function passthrough(op: Operation, forward: NextLink) { + return forward ? forward(op) : Observable.of(); +} + +function toLink(handler: RequestHandler | ApolloLink) { + return typeof handler === 'function' ? new ApolloLink(handler) : handler; +} + +function isTerminating(link: ApolloLink): boolean { + return link.request.length <= 1; +} + +class LinkError extends Error { + public link: ApolloLink; + constructor(message?: string, link?: ApolloLink) { + super(message); + this.link = link; + } +} + +export class ApolloLink { + public static empty(): ApolloLink { + return new ApolloLink(() => Observable.of()); + } + + public static from(links: ApolloLink[]): ApolloLink { + if (links.length === 0) return ApolloLink.empty(); + return links.map(toLink).reduce((x, y) => x.concat(y)) as ApolloLink; + } + + public static split( + test: (op: Operation) => boolean, + left: ApolloLink | RequestHandler, + right?: ApolloLink | RequestHandler, + ): ApolloLink { + const leftLink = toLink(left); + const rightLink = toLink(right || new ApolloLink(passthrough)); + + if (isTerminating(leftLink) && isTerminating(rightLink)) { + return new ApolloLink(operation => { + return test(operation) + ? leftLink.request(operation) || Observable.of() + : rightLink.request(operation) || Observable.of(); + }); + } else { + return new ApolloLink((operation, forward) => { + return test(operation) + ? leftLink.request(operation, forward) || Observable.of() + : rightLink.request(operation, forward) || Observable.of(); + }); + } + } + + public static execute( + link: ApolloLink, + operation: GraphQLRequest, + ): Observable { + return ( + link.request( + createOperation( + operation.context, + transformOperation(validateOperation(operation)), + ), + ) || Observable.of() + ); + } + + public static concat( + first: ApolloLink | RequestHandler, + second: ApolloLink | RequestHandler, + ) { + const firstLink = toLink(first); + if (isTerminating(firstLink)) { + invariant.warn( + new LinkError( + `You are calling concat on a terminating link, which will have no effect`, + firstLink, + ), + ); + return firstLink; + } + const nextLink = toLink(second); + + if (isTerminating(nextLink)) { + return new ApolloLink( + operation => + firstLink.request( + operation, + op => nextLink.request(op) || Observable.of(), + ) || Observable.of(), + ); + } else { + return new ApolloLink((operation, forward) => { + return ( + firstLink.request(operation, op => { + return nextLink.request(op, forward) || Observable.of(); + }) || Observable.of() + ); + }); + } + } + + constructor(request?: RequestHandler) { + if (request) this.request = request; + } + + public split( + test: (op: Operation) => boolean, + left: ApolloLink | RequestHandler, + right?: ApolloLink | RequestHandler, + ): ApolloLink { + return this.concat( + ApolloLink.split(test, left, right || new ApolloLink(passthrough)) + ); + } + + public concat(next: ApolloLink | RequestHandler): ApolloLink { + return ApolloLink.concat(this, next); + } + + public request( + operation: Operation, + forward?: NextLink, + ): Observable | null { + throw new InvariantError('request is not implemented'); + } +} diff --git a/src/link/core/__tests__/ApolloLink.ts b/src/link/core/__tests__/ApolloLink.ts new file mode 100644 index 00000000000..eed1a9bb04c --- /dev/null +++ b/src/link/core/__tests__/ApolloLink.ts @@ -0,0 +1,1052 @@ +import gql from 'graphql-tag'; +import { print } from 'graphql/language/printer'; + +import { Observable } from '../../../util/Observable'; +import { FetchResult, Operation, NextLink, GraphQLRequest } from '../types'; +import { ApolloLink } from '../ApolloLink'; + +export class SetContextLink extends ApolloLink { + constructor( + private setContext: ( + context: Record, + ) => Record = c => c, + ) { + super(); + } + + public request( + operation: Operation, + forward: NextLink, + ): Observable { + operation.setContext(this.setContext(operation.getContext())); + return forward(operation); + } +} + +export const sampleQuery = gql` + query SampleQuery { + stub { + id + } + } +`; + +function checkCalls(calls: any[] = [], results: Array) { + expect(calls.length).toBe(results.length); + calls.map((call, i) => expect(call.data).toEqual(results[i])); +} + +interface TestResultType { + link: ApolloLink; + results?: any[]; + query?: string; + done?: () => void; + context?: any; + variables?: any; +} + +export function testLinkResults(params: TestResultType) { + const { link, context, variables } = params; + const results = params.results || []; + const query = params.query || sampleQuery; + const done = params.done || (() => void 0); + + const spy = jest.fn(); + ApolloLink.execute(link, { query, context, variables }).subscribe({ + next: spy, + error: error => { + expect(error).toEqual(results.pop()); + checkCalls(spy.mock.calls[0], results); + if (done) { + done(); + } + }, + complete: () => { + checkCalls(spy.mock.calls[0], results); + if (done) { + done(); + } + }, + }); +} + +export const setContext = () => ({ add: 1 }); + +describe('ApolloClient', () => { + describe('context', () => { + it('should merge context when using a function', done => { + const returnOne = new SetContextLink(setContext); + const mock = new ApolloLink((op, forward) => { + op.setContext(({ add }) => ({ add: add + 2 })); + op.setContext(() => ({ substract: 1 })); + + return forward(op); + }); + const link = returnOne.concat(mock).concat(op => { + expect(op.getContext()).toEqual({ + add: 3, + substract: 1, + }); + return Observable.of({ data: op.getContext().add }); + }); + + testLinkResults({ + link, + results: [3], + done, + }); + }); + + it('should merge context when not using a function', done => { + const returnOne = new SetContextLink(setContext); + const mock = new ApolloLink((op, forward) => { + op.setContext({ add: 3 }); + op.setContext({ substract: 1 }); + + return forward(op); + }); + const link = returnOne.concat(mock).concat(op => { + expect(op.getContext()).toEqual({ + add: 3, + substract: 1, + }); + return Observable.of({ data: op.getContext().add }); + }); + + testLinkResults({ + link, + results: [3], + done, + }); + }); + }); + + describe('concat', () => { + it('should concat a function', done => { + const returnOne = new SetContextLink(setContext); + const link = returnOne.concat((operation, forward) => { + return Observable.of({ data: { count: operation.getContext().add } }); + }); + + testLinkResults({ + link, + results: [{ count: 1 }], + done, + }); + }); + + it('should concat a Link', done => { + const returnOne = new SetContextLink(setContext); + const mock = new ApolloLink(op => + Observable.of({ data: op.getContext().add }), + ); + const link = returnOne.concat(mock); + + testLinkResults({ + link, + results: [1], + done, + }); + }); + + it("should pass error to observable's error", done => { + const error = new Error('thrown'); + const returnOne = new SetContextLink(setContext); + const mock = new ApolloLink( + op => + new Observable(observer => { + observer.next({ data: op.getContext().add }); + observer.error(error); + }), + ); + const link = returnOne.concat(mock); + + testLinkResults({ + link, + results: [1, error], + done, + }); + }); + + it('should concat a Link and function', done => { + const returnOne = new SetContextLink(setContext); + const mock = new ApolloLink((op, forward) => { + op.setContext(({ add }) => ({ add: add + 2 })); + return forward(op); + }); + const link = returnOne.concat(mock).concat(op => { + return Observable.of({ data: op.getContext().add }); + }); + + testLinkResults({ + link, + results: [3], + done, + }); + }); + + it('should concat a function and Link', done => { + const returnOne = new SetContextLink(setContext); + const mock = new ApolloLink((op, forward) => + Observable.of({ data: op.getContext().add }), + ); + + const link = returnOne + .concat((operation, forward) => { + operation.setContext({ + add: operation.getContext().add + 2, + }); + return forward(operation); + }) + .concat(mock); + testLinkResults({ + link, + results: [3], + done, + }); + }); + + it('should concat two functions', done => { + const returnOne = new SetContextLink(setContext); + const link = returnOne + .concat((operation, forward) => { + operation.setContext({ + add: operation.getContext().add + 2, + }); + return forward(operation); + }) + .concat((op, forward) => Observable.of({ data: op.getContext().add })); + testLinkResults({ + link, + results: [3], + done, + }); + }); + + it('should concat two Links', done => { + const returnOne = new SetContextLink(setContext); + const mock1 = new ApolloLink((operation, forward) => { + operation.setContext({ + add: operation.getContext().add + 2, + }); + return forward(operation); + }); + const mock2 = new ApolloLink((op, forward) => + Observable.of({ data: op.getContext().add }), + ); + + const link = returnOne.concat(mock1).concat(mock2); + testLinkResults({ + link, + results: [3], + done, + }); + }); + + it("should return an link that can be concat'd multiple times", done => { + const returnOne = new SetContextLink(setContext); + const mock1 = new ApolloLink((operation, forward) => { + operation.setContext({ + add: operation.getContext().add + 2, + }); + return forward(operation); + }); + const mock2 = new ApolloLink((op, forward) => + Observable.of({ data: op.getContext().add + 2 }), + ); + const mock3 = new ApolloLink((op, forward) => + Observable.of({ data: op.getContext().add + 3 }), + ); + const link = returnOne.concat(mock1); + + testLinkResults({ + link: link.concat(mock2), + results: [5], + }); + testLinkResults({ + link: link.concat(mock3), + results: [6], + done, + }); + }); + }); + + describe('empty', () => { + it('should returns an immediately completed Observable', done => { + testLinkResults({ + link: ApolloLink.empty(), + done, + }); + }); + }); + + describe('execute', () => { + it('transforms an opearation with context into something serlizable', done => { + const query = gql` + { + id + } + `; + const link = new ApolloLink(operation => { + const str = JSON.stringify({ + ...operation, + query: print(operation.query), + }); + + expect(str).toBe( + JSON.stringify({ + variables: { id: 1 }, + extensions: { cache: true }, + operationName: null, + query: print(operation.query), + }), + ); + return Observable.of(); + }); + const noop = () => {}; + ApolloLink.execute(link, { + query, + variables: { id: 1 }, + extensions: { cache: true }, + }).subscribe(noop, noop, done); + }); + + describe('execute', () => { + let _warn: (message?: any, ...originalParams: any[]) => void; + + beforeEach(() => { + _warn = console.warn; + console.warn = jest.fn(warning => { + expect(warning).toBe(`query should either be a string or GraphQL AST`); + }); + }); + + afterEach(() => { + console.warn = _warn; + }); + + it('should return an empty observable when a link returns null', done => { + const link = new ApolloLink(); + link.request = () => null; + testLinkResults({ + link, + results: [], + done, + }); + }); + + it('should return an empty observable when a link is empty', done => { + testLinkResults({ + link: ApolloLink.empty(), + results: [], + done, + }); + }); + + it("should return an empty observable when a concat'd link returns null", done => { + const link = new ApolloLink((operation, forward) => { + return forward(operation); + }).concat(() => null); + testLinkResults({ + link, + results: [], + done, + }); + }); + + it('should return an empty observable when a split link returns null', done => { + let context = { test: true }; + const link = new SetContextLink(() => context).split( + op => op.getContext().test, + () => Observable.of(), + () => null, + ); + testLinkResults({ + link, + results: [], + }); + context.test = false; + testLinkResults({ + link, + results: [], + done, + }); + }); + + it('should set a default context, variable, query and operationName on a copy of operation', done => { + const operation = { + query: gql` + { + id + } + `, + }; + const link = new ApolloLink(op => { + expect(operation['operationName']).toBeUndefined(); + expect(operation['variables']).toBeUndefined(); + expect(operation['context']).toBeUndefined(); + expect(operation['extensions']).toBeUndefined(); + expect(op['operationName']).toBeDefined(); + expect(op['variables']).toBeDefined(); + expect(op['context']).toBeUndefined(); + expect(op['extensions']).toBeDefined(); + return Observable.of(); + }); + + ApolloLink.execute(link, operation).subscribe({ + complete: done, + }); + }); + }) + }); + + describe('from', () => { + const uniqueOperation: GraphQLRequest = { + query: sampleQuery, + context: { name: 'uniqueName' }, + operationName: 'SampleQuery', + extensions: {}, + }; + + it('should create an observable that completes when passed an empty array', done => { + const observable = ApolloLink.execute(ApolloLink.from([]), { + query: sampleQuery, + }); + observable.subscribe(() => expect(false), () => expect(false), done); + }); + + it('can create chain of one', () => { + expect(() => ApolloLink.from([new ApolloLink()])).not.toThrow(); + }); + + it('can create chain of two', () => { + expect(() => + ApolloLink.from([ + new ApolloLink((operation, forward) => forward(operation)), + new ApolloLink(), + ]), + ).not.toThrow(); + }); + + it('should receive result of one link', done => { + const data: FetchResult = { + data: { + hello: 'world', + }, + }; + const chain = ApolloLink.from([new ApolloLink(() => Observable.of(data))]); + // Smoke tests execute as a static method + const observable = ApolloLink.execute(chain, uniqueOperation); + observable.subscribe({ + next: actualData => { + expect(data).toEqual(actualData); + }, + error: () => { + throw new Error(); + }, + complete: () => done(), + }); + }); + + it('should accept AST query and pass AST to link', () => { + const astOperation = { + ...uniqueOperation, + query: sampleQuery, + }; + + const stub = jest.fn(); + + const chain = ApolloLink.from([new ApolloLink(stub)]); + ApolloLink.execute(chain, astOperation); + + expect(stub).toBeCalledWith({ + query: sampleQuery, + operationName: 'SampleQuery', + variables: {}, + extensions: {}, + }); + }); + + it('should pass operation from one link to next with modifications', done => { + const chain = ApolloLink.from([ + new ApolloLink((op, forward) => + forward({ + ...op, + query: sampleQuery, + }), + ), + new ApolloLink(op => { + expect({ + extensions: {}, + operationName: 'SampleQuery', + query: sampleQuery, + variables: {}, + }).toEqual(op); + return done(); + }), + ]); + ApolloLink.execute(chain, uniqueOperation); + }); + + it('should pass result of one link to another with forward', done => { + const data: FetchResult = { + data: { + hello: 'world', + }, + }; + + const chain = ApolloLink.from([ + new ApolloLink((op, forward) => { + const observable = forward(op); + + observable.subscribe({ + next: actualData => { + expect(data).toEqual(actualData); + }, + error: () => { + throw new Error(); + }, + complete: done, + }); + + return observable; + }), + new ApolloLink(() => Observable.of(data)), + ]); + ApolloLink.execute(chain, uniqueOperation); + }); + + it('should receive final result of two link chain', done => { + const data: FetchResult = { + data: { + hello: 'world', + }, + }; + + const chain = ApolloLink.from([ + new ApolloLink((op, forward) => { + const observable = forward(op); + + return new Observable(observer => { + observable.subscribe({ + next: actualData => { + expect(data).toEqual(actualData); + observer.next({ + data: { + ...actualData.data, + modification: 'unique', + }, + }); + }, + error: error => observer.error(error), + complete: () => observer.complete(), + }); + }); + }), + new ApolloLink(() => Observable.of(data)), + ]); + + const result = ApolloLink.execute(chain, uniqueOperation); + + result.subscribe({ + next: modifiedData => { + expect({ + data: { + ...data.data, + modification: 'unique', + }, + }).toEqual(modifiedData); + }, + error: () => { + throw new Error(); + }, + complete: done, + }); + }); + + it('should chain together a function with links', done => { + const add1 = new ApolloLink((operation: Operation, forward: NextLink) => { + operation.setContext(({ num }) => ({ num: num + 1 })); + return forward(operation); + }); + const add1Link = new ApolloLink((operation, forward) => { + operation.setContext(({ num }) => ({ num: num + 1 })); + return forward(operation); + }); + + const link = ApolloLink.from([ + add1, + add1, + add1Link, + add1, + add1Link, + new ApolloLink(operation => + Observable.of({ data: operation.getContext() }), + ), + ]); + testLinkResults({ + link, + results: [{ num: 5 }], + context: { num: 0 }, + done, + }); + }); + }); + + describe('split', () => { + it('should split two functions', done => { + const context = { add: 1 }; + const returnOne = new SetContextLink(() => context); + const link1 = returnOne.concat((operation, forward) => + Observable.of({ data: operation.getContext().add + 1 }), + ); + const link2 = returnOne.concat((operation, forward) => + Observable.of({ data: operation.getContext().add + 2 }), + ); + const link = returnOne.split( + operation => operation.getContext().add === 1, + link1, + link2, + ); + + testLinkResults({ + link, + results: [2], + }); + + context.add = 2; + + testLinkResults({ + link, + results: [4], + done, + }); + }); + + it('should split two Links', done => { + const context = { add: 1 }; + const returnOne = new SetContextLink(() => context); + const link1 = returnOne.concat( + new ApolloLink((operation, forward) => + Observable.of({ data: operation.getContext().add + 1 }), + ), + ); + const link2 = returnOne.concat( + new ApolloLink((operation, forward) => + Observable.of({ data: operation.getContext().add + 2 }), + ), + ); + const link = returnOne.split( + operation => operation.getContext().add === 1, + link1, + link2, + ); + + testLinkResults({ + link, + results: [2], + }); + + context.add = 2; + + testLinkResults({ + link, + results: [4], + done, + }); + }); + + it('should split a link and a function', done => { + const context = { add: 1 }; + const returnOne = new SetContextLink(() => context); + const link1 = returnOne.concat((operation, forward) => + Observable.of({ data: operation.getContext().add + 1 }), + ); + const link2 = returnOne.concat( + new ApolloLink((operation, forward) => + Observable.of({ data: operation.getContext().add + 2 }), + ), + ); + const link = returnOne.split( + operation => operation.getContext().add === 1, + link1, + link2, + ); + + testLinkResults({ + link, + results: [2], + }); + + context.add = 2; + + testLinkResults({ + link, + results: [4], + done, + }); + }); + + it('should allow concat after split to be join', done => { + const context = { test: true, add: 1 }; + const start = new SetContextLink(() => ({ ...context })); + const link = start + .split( + operation => operation.getContext().test, + (operation, forward) => { + operation.setContext(({ add }) => ({ add: add + 1 })); + return forward(operation); + }, + (operation, forward) => { + operation.setContext(({ add }) => ({ add: add + 2 })); + return forward(operation); + }, + ) + .concat(operation => + Observable.of({ data: operation.getContext().add }), + ); + + testLinkResults({ + link, + context, + results: [2], + }); + + context.test = false; + + testLinkResults({ + link, + context, + results: [3], + done, + }); + }); + + it('should allow default right to be empty or passthrough when forward available', done => { + let context = { test: true }; + const start = new SetContextLink(() => context); + const link = start.split( + operation => operation.getContext().test, + operation => + Observable.of({ + data: { + count: 1, + }, + }), + ); + const concat = link.concat(operation => + Observable.of({ + data: { + count: 2, + }, + }), + ); + + testLinkResults({ + link, + results: [{ count: 1 }], + }); + + context.test = false; + + testLinkResults({ + link, + results: [], + }); + + testLinkResults({ + link: concat, + results: [{ count: 2 }], + done, + }); + }); + + it('should create filter when single link passed in', done => { + const link = ApolloLink.split( + operation => operation.getContext().test, + (operation, forward) => Observable.of({ data: { count: 1 } }), + ); + + let context = { test: true }; + + testLinkResults({ + link, + results: [{ count: 1 }], + context, + }); + + context.test = false; + + testLinkResults({ + link, + results: [], + context, + done, + }); + }); + + it('should split two functions', done => { + const link = ApolloLink.split( + operation => operation.getContext().test, + (operation, forward) => Observable.of({ data: { count: 1 } }), + (operation, forward) => Observable.of({ data: { count: 2 } }), + ); + + let context = { test: true }; + + testLinkResults({ + link, + results: [{ count: 1 }], + context, + }); + + context.test = false; + + testLinkResults({ + link, + results: [{ count: 2 }], + context, + done, + }); + }); + + it('should split two Links', done => { + const link = ApolloLink.split( + operation => operation.getContext().test, + (operation, forward) => Observable.of({ data: { count: 1 } }), + new ApolloLink((operation, forward) => + Observable.of({ data: { count: 2 } }), + ), + ); + + let context = { test: true }; + + testLinkResults({ + link, + results: [{ count: 1 }], + context, + }); + + context.test = false; + + testLinkResults({ + link, + results: [{ count: 2 }], + context, + done, + }); + }); + + it('should split a link and a function', done => { + const link = ApolloLink.split( + operation => operation.getContext().test, + (operation, forward) => Observable.of({ data: { count: 1 } }), + new ApolloLink((operation, forward) => + Observable.of({ data: { count: 2 } }), + ), + ); + + let context = { test: true }; + + testLinkResults({ + link, + results: [{ count: 1 }], + context, + }); + + context.test = false; + + testLinkResults({ + link, + results: [{ count: 2 }], + context, + done, + }); + }); + + it('should allow concat after split to be join', done => { + const context = { test: true }; + const link = ApolloLink.split( + operation => operation.getContext().test, + (operation, forward) => + forward(operation).map(data => ({ + data: { count: data.data.count + 1 }, + })), + ).concat(() => Observable.of({ data: { count: 1 } })); + + testLinkResults({ + link, + context, + results: [{ count: 2 }], + }); + + context.test = false; + + testLinkResults({ + link, + context, + results: [{ count: 1 }], + done, + }); + }); + + it('should allow default right to be passthrough', done => { + const context = { test: true }; + const link = ApolloLink.split( + operation => operation.getContext().test, + operation => Observable.of({ data: { count: 2 } }), + ).concat(operation => Observable.of({ data: { count: 1 } })); + + testLinkResults({ + link, + context, + results: [{ count: 2 }], + }); + + context.test = false; + + testLinkResults({ + link, + context, + results: [{ count: 1 }], + done, + }); + }); + }); + + describe('Terminating links', () => { + const _warn = console.warn; + const warningStub = jest.fn(warning => { + expect(warning.message).toBe( + `You are calling concat on a terminating link, which will have no effect`, + ); + }); + const data = { + stub: 'data', + }; + + beforeAll(() => { + console.warn = warningStub; + }); + + beforeEach(() => { + warningStub.mockClear(); + }); + + afterAll(() => { + console.warn = _warn; + }); + + describe('split', () => { + it('should not warn if attempting to split a terminating and non-terminating Link', () => { + const split = ApolloLink.split( + () => true, + operation => Observable.of({ data }), + (operation, forward) => forward(operation), + ); + split.concat((operation, forward) => forward(operation)); + expect(warningStub).not.toBeCalled(); + }); + + it('should warn if attempting to concat to split two terminating links', () => { + const split = ApolloLink.split( + () => true, + operation => Observable.of({ data }), + operation => Observable.of({ data }), + ); + expect(split.concat((operation, forward) => forward(operation))).toEqual( + split, + ); + expect(warningStub).toHaveBeenCalledTimes(1); + }); + + it('should warn if attempting to split to split two terminating links', () => { + const split = ApolloLink.split( + () => true, + operation => Observable.of({ data }), + operation => Observable.of({ data }), + ); + expect( + split.split( + () => true, + (operation, forward) => forward(operation), + (operation, forward) => forward(operation), + ), + ).toEqual(split); + expect(warningStub).toHaveBeenCalledTimes(1); + }); + }); + + describe('from', () => { + it('should not warn if attempting to form a terminating then non-terminating Link', () => { + ApolloLink.from([ + (operation, forward) => forward(operation), + operation => Observable.of({ data }), + ]); + expect(warningStub).not.toBeCalled(); + }); + + it('should warn if attempting to add link after termination', () => { + ApolloLink.from([ + (operation, forward) => forward(operation), + operation => Observable.of({ data }), + (operation, forward) => forward(operation), + ]); + expect(warningStub).toHaveBeenCalledTimes(1); + }); + + it('should warn if attempting to add link after termination', () => { + ApolloLink.from([ + new ApolloLink((operation, forward) => forward(operation)), + new ApolloLink(operation => Observable.of({ data })), + new ApolloLink((operation, forward) => forward(operation)), + ]); + expect(warningStub).toHaveBeenCalledTimes(1); + }); + }); + + describe('concat', () => { + it('should warn if attempting to concat to a terminating Link from function', () => { + const link = new ApolloLink(operation => Observable.of({ data })); + expect(ApolloLink.concat(link, (operation, forward) => forward(operation))).toEqual( + link, + ); + expect(warningStub).toHaveBeenCalledTimes(1); + expect(warningStub.mock.calls[0][0].link).toEqual(link); + }); + + it('should warn if attempting to concat to a terminating Link', () => { + const link = new ApolloLink(operation => Observable.of()); + expect(link.concat((operation, forward) => forward(operation))).toEqual( + link, + ); + expect(warningStub).toHaveBeenCalledTimes(1); + expect(warningStub.mock.calls[0][0].link).toEqual(link); + }); + + it('should not warn if attempting concat a terminating Link at end', () => { + const link = new ApolloLink((operation, forward) => forward(operation)); + link.concat(operation => Observable.of()); + expect(warningStub).not.toBeCalled(); + }); + }); + + describe('warning', () => { + it('should include link that terminates', () => { + const terminatingLink = new ApolloLink(operation => + Observable.of({ data }), + ); + ApolloLink.from([ + new ApolloLink((operation, forward) => forward(operation)), + new ApolloLink((operation, forward) => forward(operation)), + terminatingLink, + new ApolloLink((operation, forward) => forward(operation)), + new ApolloLink((operation, forward) => forward(operation)), + new ApolloLink(operation => Observable.of({ data })), + new ApolloLink((operation, forward) => forward(operation)), + ]); + expect(warningStub).toHaveBeenCalledTimes(4); + }); + }); + }); +}); diff --git a/src/link/core/concat.ts b/src/link/core/concat.ts new file mode 100644 index 00000000000..a6cbcee58e2 --- /dev/null +++ b/src/link/core/concat.ts @@ -0,0 +1,3 @@ +import { ApolloLink } from './ApolloLink'; + +export const concat = ApolloLink.concat; diff --git a/src/link/core/empty.ts b/src/link/core/empty.ts new file mode 100644 index 00000000000..8d93c8ce90d --- /dev/null +++ b/src/link/core/empty.ts @@ -0,0 +1,3 @@ +import { ApolloLink } from './ApolloLink'; + +export const empty = ApolloLink.empty; diff --git a/src/link/core/execute.ts b/src/link/core/execute.ts new file mode 100644 index 00000000000..49217fa8ad3 --- /dev/null +++ b/src/link/core/execute.ts @@ -0,0 +1,3 @@ +import { ApolloLink } from './ApolloLink'; + +export const execute = ApolloLink.execute; diff --git a/src/link/core/from.ts b/src/link/core/from.ts new file mode 100644 index 00000000000..c2bed562ea6 --- /dev/null +++ b/src/link/core/from.ts @@ -0,0 +1,3 @@ +import { ApolloLink } from './ApolloLink'; + +export const from = ApolloLink.from; diff --git a/src/link/core/index.ts b/src/link/core/index.ts new file mode 100644 index 00000000000..8093e7c6cd2 --- /dev/null +++ b/src/link/core/index.ts @@ -0,0 +1,8 @@ +export { empty } from './empty'; +export { from } from './from'; +export { split } from './split'; +export { concat } from './concat'; +export { execute } from './execute'; +export { ApolloLink } from './ApolloLink'; + +export * from './types'; diff --git a/src/link/core/split.ts b/src/link/core/split.ts new file mode 100644 index 00000000000..c109ebd29f4 --- /dev/null +++ b/src/link/core/split.ts @@ -0,0 +1,3 @@ +import { ApolloLink } from './ApolloLink'; + +export const split = ApolloLink.split; diff --git a/src/link/core/types.ts b/src/link/core/types.ts new file mode 100644 index 00000000000..4a15f5035c2 --- /dev/null +++ b/src/link/core/types.ts @@ -0,0 +1,38 @@ +import { DocumentNode } from 'graphql/language/ast'; +import { ExecutionResult } from 'graphql/execution/execute'; +export { ExecutionResult, DocumentNode }; + +import { Observable } from '../../util/Observable'; + +export interface GraphQLRequest { + query: DocumentNode; + variables?: Record; + operationName?: string; + context?: Record; + extensions?: Record; +} + +export interface Operation { + query: DocumentNode; + variables: Record; + operationName: string; + extensions: Record; + setContext: (context: Record) => Record; + getContext: () => Record; +} + +export type FetchResult< + TData = { [key: string]: any }, + C = Record, + E = Record +> = ExecutionResult & { + extensions?: E; + context?: C; +}; + +export type NextLink = (operation: Operation) => Observable; + +export type RequestHandler = ( + operation: Operation, + forward: NextLink, +) => Observable | null; diff --git a/src/link/http/HttpLink.ts b/src/link/http/HttpLink.ts new file mode 100644 index 00000000000..cc2a8d5fa14 --- /dev/null +++ b/src/link/http/HttpLink.ts @@ -0,0 +1,10 @@ +import { ApolloLink, RequestHandler } from '../core'; +import { HttpOptions } from './selectHttpOptionsAndBody'; +import { createHttpLink } from './createHttpLink'; + +export class HttpLink extends ApolloLink { + public requester: RequestHandler; + constructor(public options: HttpOptions = {}) { + super(createHttpLink(options).request); + } +} diff --git a/src/link/http/__tests__/HttpLink.ts b/src/link/http/__tests__/HttpLink.ts new file mode 100644 index 00000000000..981ebb23925 --- /dev/null +++ b/src/link/http/__tests__/HttpLink.ts @@ -0,0 +1,1111 @@ +import gql from 'graphql-tag'; +import fetchMock from 'fetch-mock'; +import { print } from 'graphql'; + +import { Observable } from '../../../util/Observable'; +import { ApolloLink, execute } from '../../core'; +import { HttpLink } from '../HttpLink'; +import { createHttpLink } from '../createHttpLink'; + +const sampleQuery = gql` + query SampleQuery { + stub { + id + } + } +`; + +const sampleMutation = gql` + mutation SampleMutation { + stub { + id + } + } +`; + +const makeCallback = (done, body) => { + return (...args) => { + try { + body(...args); + done(); + } catch (error) { + done.fail(error); + } + }; +}; + +const convertBatchedBody = body => { + const parsed = JSON.parse(body); + return parsed; +}; + +const makePromise = + res => new Promise((resolve) => setTimeout(() => resolve(res))); + +describe('HttpLink', () => { + describe('General', () => { + const data = { data: { hello: 'world' } }; + const data2 = { data: { hello: 'everyone' } }; + const mockError = { throws: new TypeError('mock me') }; + let subscriber; + + beforeEach(() => { + fetchMock.restore(); + fetchMock.post('begin:/data2', makePromise(data2)); + fetchMock.post('begin:/data', makePromise(data)); + fetchMock.post('begin:/error', mockError); + fetchMock.post('begin:/apollo', makePromise(data)); + + fetchMock.get('begin:/data', makePromise(data)); + fetchMock.get('begin:/data2', makePromise(data2)); + + const next = jest.fn(); + const error = jest.fn(); + const complete = jest.fn(); + + subscriber = { + next, + error, + complete, + }; + }); + + afterEach(() => { + fetchMock.restore(); + }); + + it('does not need any constructor arguments', () => { + expect(() => new HttpLink()).not.toThrow(); + }); + + it('constructor creates link that can call next and then complete', done => { + const next = jest.fn(); + const link = new HttpLink({ uri: '/data' }); + const observable = execute(link, { + query: sampleQuery, + }); + observable.subscribe({ + next, + error: error => expect(false), + complete: () => { + expect(next).toHaveBeenCalledTimes(1); + done(); + }, + }); + }); + + it('supports using a GET request', done => { + const variables = { params: 'stub' }; + const extensions = { myExtension: 'foo' }; + + const link = createHttpLink({ + uri: '/data', + fetchOptions: { method: 'GET' }, + includeExtensions: true, + }); + + execute(link, { query: sampleQuery, variables, extensions }).subscribe({ + next: makeCallback(done, result => { + const [uri, options] = fetchMock.lastCall(); + const { method, body } = options; + expect(body).toBeUndefined(); + expect(method).toBe('GET'); + expect(uri).toBe( + '/data?query=query%20SampleQuery%20%7B%0A%20%20stub%20%7B%0A%20%20%20%20id%0A%20%20%7D%0A%7D%0A&operationName=SampleQuery&variables=%7B%22params%22%3A%22stub%22%7D&extensions=%7B%22myExtension%22%3A%22foo%22%7D', + ); + }), + error: error => done.fail(error), + }); + }); + + it('supports using a GET request with search', done => { + const variables = { params: 'stub' }; + + const link = createHttpLink({ + uri: '/data?foo=bar', + fetchOptions: { method: 'GET' }, + }); + + execute(link, { query: sampleQuery, variables }).subscribe({ + next: makeCallback(done, result => { + const [uri, options] = fetchMock.lastCall(); + const { method, body } = options; + expect(body).toBeUndefined(); + expect(method).toBe('GET'); + expect(uri).toBe( + '/data?foo=bar&query=query%20SampleQuery%20%7B%0A%20%20stub%20%7B%0A%20%20%20%20id%0A%20%20%7D%0A%7D%0A&operationName=SampleQuery&variables=%7B%22params%22%3A%22stub%22%7D', + ); + }), + error: error => done.fail(error), + }); + }); + + it('supports using a GET request on the context', done => { + const variables = { params: 'stub' }; + const link = createHttpLink({ + uri: '/data', + }); + + execute(link, { + query: sampleQuery, + variables, + context: { + fetchOptions: { method: 'GET' }, + }, + }).subscribe( + makeCallback(done, result => { + const [uri, options] = fetchMock.lastCall(); + const { method, body } = options; + expect(body).toBeUndefined(); + expect(method).toBe('GET'); + expect(uri).toBe( + '/data?query=query%20SampleQuery%20%7B%0A%20%20stub%20%7B%0A%20%20%20%20id%0A%20%20%7D%0A%7D%0A&operationName=SampleQuery&variables=%7B%22params%22%3A%22stub%22%7D', + ); + }), + ); + }); + + it('uses GET with useGETForQueries', done => { + const variables = { params: 'stub' }; + const link = createHttpLink({ + uri: '/data', + useGETForQueries: true, + }); + + execute(link, { + query: sampleQuery, + variables, + }).subscribe( + makeCallback(done, result => { + const [uri, options] = fetchMock.lastCall(); + const { method, body } = options; + expect(body).toBeUndefined(); + expect(method).toBe('GET'); + expect(uri).toBe( + '/data?query=query%20SampleQuery%20%7B%0A%20%20stub%20%7B%0A%20%20%20%20id%0A%20%20%7D%0A%7D%0A&operationName=SampleQuery&variables=%7B%22params%22%3A%22stub%22%7D', + ); + }), + ); + }); + + it('uses POST for mutations with useGETForQueries', done => { + const variables = { params: 'stub' }; + const link = createHttpLink({ + uri: '/data', + useGETForQueries: true, + }); + + execute(link, { + query: sampleMutation, + variables, + }).subscribe( + makeCallback(done, result => { + const [uri, options] = fetchMock.lastCall(); + const { method, body } = options; + expect(body).toBeDefined(); + expect(method).toBe('POST'); + expect(uri).toBe('/data'); + }), + ); + }); + + it('should add client awareness settings to request headers', done => { + const variables = { params: 'stub' }; + const link = createHttpLink({ + uri: '/data', + }); + + const clientAwareness = { + name: 'Some Client Name', + version: '1.0.1', + }; + + execute(link, { + query: sampleQuery, + variables, + context: { + clientAwareness, + }, + }).subscribe( + makeCallback(done, result => { + const [uri, options] = fetchMock.lastCall(); + const { headers } = options; + expect(headers['apollographql-client-name']).toBeDefined(); + expect(headers['apollographql-client-name']).toEqual( + clientAwareness.name, + ); + expect(headers['apollographql-client-version']).toBeDefined(); + expect(headers['apollographql-client-version']).toEqual( + clientAwareness.version, + ); + }), + ); + }); + + it('should not add empty client awareness settings to request headers', done => { + const variables = { params: 'stub' }; + const link = createHttpLink({ + uri: '/data', + }); + + const hasOwn = Object.prototype.hasOwnProperty; + const clientAwareness = {}; + execute(link, { + query: sampleQuery, + variables, + context: { + clientAwareness, + }, + }).subscribe( + makeCallback(done, result => { + const [uri, options] = fetchMock.lastCall(); + const { headers } = options; + expect(hasOwn.call(headers, 'apollographql-client-name')).toBe(false); + expect(hasOwn.call(headers, 'apollographql-client-version')).toBe( + false, + ); + }), + ); + }); + + it("throws for GET if the variables can't be stringified", done => { + const link = createHttpLink({ + uri: '/data', + useGETForQueries: true, + }); + + let b; + const a = { b }; + b = { a }; + a.b = b; + const variables = { + a, + b, + }; + execute(link, { query: sampleQuery, variables }).subscribe( + result => { + done.fail('next should have been thrown from the link'); + }, + makeCallback(done, e => { + expect(e.message).toMatch(/Variables map is not serializable/); + expect(e.parseError.message).toMatch( + /Converting circular structure to JSON/, + ); + }), + ); + }); + + it("throws for GET if the extensions can't be stringified", done => { + const link = createHttpLink({ + uri: '/data', + useGETForQueries: true, + includeExtensions: true, + }); + + let b; + const a = { b }; + b = { a }; + a.b = b; + const extensions = { + a, + b, + }; + execute(link, { query: sampleQuery, extensions }).subscribe( + result => { + done.fail('next should have been thrown from the link'); + }, + makeCallback(done, e => { + expect(e.message).toMatch(/Extensions map is not serializable/); + expect(e.parseError.message).toMatch( + /Converting circular structure to JSON/, + ); + }), + ); + }); + + it('raises warning if called with concat', () => { + const link = createHttpLink(); + const _warn = console.warn; + console.warn = warning => expect(warning['message']).toBeDefined(); + expect(link.concat((operation, forward) => forward(operation))).toEqual( + link, + ); + console.warn = _warn; + }); + + it('does not need any constructor arguments', () => { + expect(() => createHttpLink()).not.toThrow(); + }); + + it('calls next and then complete', done => { + const next = jest.fn(); + const link = createHttpLink({ uri: 'data' }); + const observable = execute(link, { + query: sampleQuery, + }); + observable.subscribe({ + next, + error: error => done.fail(error), + complete: makeCallback(done, () => { + expect(next).toHaveBeenCalledTimes(1); + }), + }); + }); + + it('calls error when fetch fails', done => { + const link = createHttpLink({ uri: 'error' }); + const observable = execute(link, { + query: sampleQuery, + }); + observable.subscribe( + result => done.fail('next should not have been called'), + makeCallback(done, error => { + expect(error).toEqual(mockError.throws); + }), + () => done.fail('complete should not have been called'), + ); + }); + + it('calls error when fetch fails', done => { + const link = createHttpLink({ uri: 'error' }); + const observable = execute(link, { + query: sampleMutation, + }); + observable.subscribe( + result => done.fail('next should not have been called'), + makeCallback(done, error => { + expect(error).toEqual(mockError.throws); + }), + () => done.fail('complete should not have been called'), + ); + }); + + it('unsubscribes without calling subscriber', done => { + const link = createHttpLink({ uri: 'data' }); + const observable = execute(link, { + query: sampleQuery, + }); + const subscription = observable.subscribe( + result => done.fail('next should not have been called'), + error => done.fail(error), + () => done.fail('complete should not have been called'), + ); + subscription.unsubscribe(); + expect(subscription.closed).toBe(true); + setTimeout(done, 50); + }); + + const verifyRequest = ( + link: ApolloLink, + after: () => void, + includeExtensions: boolean, + done: any, + ) => { + const next = jest.fn(); + const context = { info: 'stub' }; + const variables = { params: 'stub' }; + + const observable = execute(link, { + query: sampleMutation, + context, + variables, + }); + observable.subscribe({ + next, + error: error => done.fail(error), + complete: () => { + try { + let body = convertBatchedBody(fetchMock.lastCall()[1].body); + expect(body.query).toBe(print(sampleMutation)); + expect(body.variables).toEqual(variables); + expect(body.context).not.toBeDefined(); + if (includeExtensions) { + expect(body.extensions).toBeDefined(); + } else { + expect(body.extensions).not.toBeDefined(); + } + expect(next).toHaveBeenCalledTimes(1); + + after(); + } catch (e) { + done.fail(e); + } + }, + }); + }; + + it('passes all arguments to multiple fetch body including extensions', done => { + debugger; + const link = createHttpLink({ uri: 'data', includeExtensions: true }); + verifyRequest( + link, + () => verifyRequest(link, done, true, done), + true, + done, + ); + }); + + it('passes all arguments to multiple fetch body excluding extensions', done => { + const link = createHttpLink({ uri: 'data' }); + verifyRequest( + link, + () => verifyRequest(link, done, false, done), + false, + done, + ); + }); + + it('calls multiple subscribers', done => { + const link = createHttpLink({ uri: 'data' }); + const context = { info: 'stub' }; + const variables = { params: 'stub' }; + + const observable = execute(link, { + query: sampleMutation, + context, + variables, + }); + observable.subscribe(subscriber); + observable.subscribe(subscriber); + + setTimeout(() => { + expect(subscriber.next).toHaveBeenCalledTimes(2); + expect(subscriber.complete).toHaveBeenCalledTimes(2); + expect(subscriber.error).not.toHaveBeenCalled(); + done(); + }, 50); + }); + + it('calls remaining subscribers after unsubscribe', done => { + const link = createHttpLink({ uri: 'data' }); + const context = { info: 'stub' }; + const variables = { params: 'stub' }; + + const observable = execute(link, { + query: sampleMutation, + context, + variables, + }); + + observable.subscribe(subscriber); + + setTimeout(() => { + const subscription = observable.subscribe(subscriber); + subscription.unsubscribe(); + }, 10); + + setTimeout( + makeCallback(done, () => { + expect(subscriber.next).toHaveBeenCalledTimes(1); + expect(subscriber.complete).toHaveBeenCalledTimes(1); + expect(subscriber.error).not.toHaveBeenCalled(); + done(); + }), + 50, + ); + }); + + it('allows for dynamic endpoint setting', done => { + const variables = { params: 'stub' }; + const link = createHttpLink({ uri: 'data' }); + + execute(link, { + query: sampleQuery, + variables, + context: { uri: 'data2' }, + }).subscribe(result => { + expect(result).toEqual(data2); + done(); + }); + }); + + it('adds headers to the request from the context', done => { + const variables = { params: 'stub' }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + headers: { authorization: '1234' }, + }); + return forward(operation).map(result => { + const { headers } = operation.getContext(); + try { + expect(headers).toBeDefined(); + } catch (e) { + done.fail(e); + } + return result; + }); + }); + const link = middleware.concat(createHttpLink({ uri: 'data' })); + + execute(link, { query: sampleQuery, variables }).subscribe( + makeCallback(done, result => { + const headers = fetchMock.lastCall()[1].headers; + expect(headers.authorization).toBe('1234'); + expect(headers['content-type']).toBe('application/json'); + expect(headers.accept).toBe('*/*'); + }), + ); + }); + + it('adds headers to the request from the setup', done => { + const variables = { params: 'stub' }; + const link = createHttpLink({ + uri: 'data', + headers: { authorization: '1234' }, + }); + + execute(link, { query: sampleQuery, variables }).subscribe( + makeCallback(done, result => { + const headers = fetchMock.lastCall()[1].headers; + expect(headers.authorization).toBe('1234'); + expect(headers['content-type']).toBe('application/json'); + expect(headers.accept).toBe('*/*'); + }), + ); + }); + + it('prioritizes context headers over setup headers', done => { + const variables = { params: 'stub' }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + headers: { authorization: '1234' }, + }); + return forward(operation); + }); + const link = middleware.concat( + createHttpLink({ uri: 'data', headers: { authorization: 'no user' } }), + ); + + execute(link, { query: sampleQuery, variables }).subscribe( + makeCallback(done, result => { + const headers = fetchMock.lastCall()[1].headers; + expect(headers.authorization).toBe('1234'); + expect(headers['content-type']).toBe('application/json'); + expect(headers.accept).toBe('*/*'); + }), + ); + }); + + it('adds headers to the request from the context on an operation', done => { + const variables = { params: 'stub' }; + const link = createHttpLink({ uri: 'data' }); + + const context = { + headers: { authorization: '1234' }, + }; + execute(link, { + query: sampleQuery, + variables, + context, + }).subscribe( + makeCallback(done, result => { + const headers = fetchMock.lastCall()[1].headers; + expect(headers.authorization).toBe('1234'); + expect(headers['content-type']).toBe('application/json'); + expect(headers.accept).toBe('*/*'); + }), + ); + }); + + it('adds creds to the request from the context', done => { + const variables = { params: 'stub' }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + credentials: 'same-team-yo', + }); + return forward(operation); + }); + const link = middleware.concat(createHttpLink({ uri: 'data' })); + + execute(link, { query: sampleQuery, variables }).subscribe( + makeCallback(done, result => { + const creds = fetchMock.lastCall()[1].credentials; + expect(creds).toBe('same-team-yo'); + }), + ); + }); + + it('adds creds to the request from the setup', done => { + const variables = { params: 'stub' }; + const link = createHttpLink({ uri: 'data', credentials: 'same-team-yo' }); + + execute(link, { query: sampleQuery, variables }).subscribe( + makeCallback(done, result => { + const creds = fetchMock.lastCall()[1].credentials; + expect(creds).toBe('same-team-yo'); + }), + ); + }); + + it('prioritizes creds from the context over the setup', done => { + const variables = { params: 'stub' }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + credentials: 'same-team-yo', + }); + return forward(operation); + }); + const link = middleware.concat( + createHttpLink({ uri: 'data', credentials: 'error' }), + ); + + execute(link, { query: sampleQuery, variables }).subscribe( + makeCallback(done, result => { + const creds = fetchMock.lastCall()[1].credentials; + expect(creds).toBe('same-team-yo'); + }), + ); + }); + + it('adds uri to the request from the context', done => { + const variables = { params: 'stub' }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + uri: 'data', + }); + return forward(operation); + }); + const link = middleware.concat(createHttpLink()); + + execute(link, { query: sampleQuery, variables }).subscribe( + makeCallback(done, result => { + const uri = fetchMock.lastUrl(); + expect(uri).toBe('/data'); + }), + ); + }); + + it('adds uri to the request from the setup', done => { + const variables = { params: 'stub' }; + const link = createHttpLink({ uri: 'data' }); + + execute(link, { query: sampleQuery, variables }).subscribe( + makeCallback(done, result => { + const uri = fetchMock.lastUrl(); + expect(uri).toBe('/data'); + }), + ); + }); + + it('prioritizes context uri over setup uri', done => { + const variables = { params: 'stub' }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + uri: 'apollo', + }); + return forward(operation); + }); + const link = middleware.concat( + createHttpLink({ uri: 'data', credentials: 'error' }), + ); + + execute(link, { query: sampleQuery, variables }).subscribe( + makeCallback(done, result => { + const uri = fetchMock.lastUrl(); + + expect(uri).toBe('/apollo'); + }), + ); + }); + + it('allows uri to be a function', done => { + const variables = { params: 'stub' }; + const customFetch = (uri, options) => { + const { operationName } = convertBatchedBody(options.body); + try { + expect(operationName).toBe('SampleQuery'); + } catch (e) { + done.fail(e); + } + return fetch('dataFunc', options); + }; + + const link = createHttpLink({ fetch: customFetch }); + + execute(link, { query: sampleQuery, variables }).subscribe( + makeCallback(done, result => { + const uri = fetchMock.lastUrl(); + expect(fetchMock.lastUrl()).toBe('/dataFunc'); + }), + ); + }); + + it('adds fetchOptions to the request from the setup', done => { + const variables = { params: 'stub' }; + const link = createHttpLink({ + uri: 'data', + fetchOptions: { someOption: 'foo', mode: 'no-cors' }, + }); + + execute(link, { query: sampleQuery, variables }).subscribe( + makeCallback(done, result => { + const { someOption, mode, headers } = fetchMock.lastCall()[1]; + expect(someOption).toBe('foo'); + expect(mode).toBe('no-cors'); + expect(headers['content-type']).toBe('application/json'); + }), + ); + }); + + it('adds fetchOptions to the request from the context', done => { + const variables = { params: 'stub' }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + fetchOptions: { + someOption: 'foo', + }, + }); + return forward(operation); + }); + const link = middleware.concat(createHttpLink({ uri: 'data' })); + + execute(link, { query: sampleQuery, variables }).subscribe( + makeCallback(done, result => { + const { someOption } = fetchMock.lastCall()[1]; + expect(someOption).toBe('foo'); + done(); + }), + ); + }); + + it('prioritizes context over setup', done => { + const variables = { params: 'stub' }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + fetchOptions: { + someOption: 'foo', + }, + }); + return forward(operation); + }); + const link = middleware.concat( + createHttpLink({ uri: 'data', fetchOptions: { someOption: 'bar' } }), + ); + + execute(link, { query: sampleQuery, variables }).subscribe( + makeCallback(done, result => { + const { someOption } = fetchMock.lastCall()[1]; + expect(someOption).toBe('foo'); + }), + ); + }); + + it('allows for not sending the query with the request', done => { + const variables = { params: 'stub' }; + const middleware = new ApolloLink((operation, forward) => { + operation.setContext({ + http: { + includeQuery: false, + includeExtensions: true, + }, + }); + operation.extensions.persistedQuery = { hash: '1234' }; + return forward(operation); + }); + const link = middleware.concat(createHttpLink({ uri: 'data' })); + + execute(link, { query: sampleQuery, variables }).subscribe( + makeCallback(done, result => { + let body = convertBatchedBody(fetchMock.lastCall()[1].body); + + expect(body.query).not.toBeDefined(); + expect(body.extensions).toEqual({ persistedQuery: { hash: '1234' } }); + done(); + }), + ); + }); + + it('sets the raw response on context', done => { + const middleware = new ApolloLink((operation, forward) => { + return new Observable(ob => { + const op = forward(operation); + const sub = op.subscribe({ + next: ob.next.bind(ob), + error: ob.error.bind(ob), + complete: makeCallback(done, e => { + expect(operation.getContext().response.headers.toBeDefined); + ob.complete(); + }), + }); + + return () => { + sub.unsubscribe(); + }; + }); + }); + + const link = middleware.concat(createHttpLink({ uri: 'data', fetch })); + + execute(link, { query: sampleQuery }).subscribe( + result => { + done(); + }, + () => {}, + ); + }); + }); + + describe('Dev warnings', () => { + let oldFetch; + beforeEach(() => { + oldFetch = window.fetch; + delete window.fetch; + }); + + afterEach(() => { + window.fetch = oldFetch; + }); + + it('warns if fetch is undeclared', done => { + try { + const link = createHttpLink({ uri: 'data' }); + done.fail("warning wasn't called"); + } catch (e) { + makeCallback(done, () => + expect(e.message).toMatch(/has not been found globally/), + )(); + } + }); + + it('warns if fetch is undefined', done => { + window.fetch = undefined; + try { + const link = createHttpLink({ uri: 'data' }); + done.fail("warning wasn't called"); + } catch (e) { + makeCallback(done, () => + expect(e.message).toMatch(/has not been found globally/), + )(); + } + }); + + it('does not warn if fetch is undeclared but a fetch is passed', () => { + expect(() => { + const link = createHttpLink({ uri: 'data', fetch: () => {} }); + }).not.toThrow(); + }); + }); + + describe('Error handling', () => { + let responseBody; + const text = jest.fn(() => { + const responseBodyText = '{}'; + responseBody = JSON.parse(responseBodyText); + return Promise.resolve(responseBodyText); + }); + const textWithData = jest.fn(() => { + responseBody = { + data: { stub: { id: 1 } }, + errors: [{ message: 'dangit' }], + }; + + return Promise.resolve(JSON.stringify(responseBody)); + }); + + const textWithErrors = jest.fn(() => { + responseBody = { + errors: [{ message: 'dangit' }], + }; + + return Promise.resolve(JSON.stringify(responseBody)); + }); + const fetch = jest.fn((uri, options) => { + return Promise.resolve({ text }); + }); + beforeEach(() => { + fetch.mockReset(); + }); + it('makes it easy to do stuff on a 401', done => { + const middleware = new ApolloLink((operation, forward) => { + return new Observable(ob => { + fetch.mockReturnValueOnce(Promise.resolve({ status: 401, text })); + const op = forward(operation); + const sub = op.subscribe({ + next: ob.next.bind(ob), + error: makeCallback(done, e => { + expect(e.message).toMatch(/Received status code 401/); + expect(e.statusCode).toEqual(401); + ob.error(e); + }), + complete: ob.complete.bind(ob), + }); + + return () => { + sub.unsubscribe(); + }; + }); + }); + + const link = middleware.concat(createHttpLink({ uri: 'data', fetch })); + + execute(link, { query: sampleQuery }).subscribe( + result => { + done.fail('next should have been thrown from the network'); + }, + () => {}, + ); + }); + + it('throws an error if response code is > 300', done => { + fetch.mockReturnValueOnce(Promise.resolve({ status: 400, text })); + const link = createHttpLink({ uri: 'data', fetch }); + + execute(link, { query: sampleQuery }).subscribe( + result => { + done.fail('next should have been thrown from the network'); + }, + makeCallback(done, e => { + expect(e.message).toMatch(/Received status code 400/); + expect(e.statusCode).toBe(400); + expect(e.result).toEqual(responseBody); + }), + ); + }); + it('throws an error if response code is > 300 and returns data', done => { + fetch.mockReturnValueOnce( + Promise.resolve({ status: 400, text: textWithData }), + ); + + const link = createHttpLink({ uri: 'data', fetch }); + + let called = false; + + execute(link, { query: sampleQuery }).subscribe( + result => { + called = true; + expect(result).toEqual(responseBody); + }, + e => { + expect(called).toBe(true); + expect(e.message).toMatch(/Received status code 400/); + expect(e.statusCode).toBe(400); + expect(e.result).toEqual(responseBody); + done(); + }, + ); + }); + it('throws an error if only errors are returned', done => { + fetch.mockReturnValueOnce( + Promise.resolve({ status: 400, text: textWithErrors }), + ); + + const link = createHttpLink({ uri: 'data', fetch }); + + let called = false; + + execute(link, { query: sampleQuery }).subscribe( + result => { + done.fail('should not have called result because we have no data'); + }, + e => { + expect(e.message).toMatch(/Received status code 400/); + expect(e.statusCode).toBe(400); + expect(e.result).toEqual(responseBody); + done(); + }, + ); + }); + it('throws an error if empty response from the server ', done => { + fetch.mockReturnValueOnce(Promise.resolve({ text })); + text.mockReturnValueOnce(Promise.resolve('{ "body": "boo" }')); + const link = createHttpLink({ uri: 'data', fetch }); + + execute(link, { query: sampleQuery }).subscribe( + result => { + done.fail('next should have been thrown from the network'); + }, + makeCallback(done, e => { + expect(e.message).toMatch( + /Server response was missing for query 'SampleQuery'/, + ); + }), + ); + }); + it("throws if the body can't be stringified", done => { + fetch.mockReturnValueOnce(Promise.resolve({ data: {}, text })); + const link = createHttpLink({ uri: 'data', fetch }); + + let b; + const a = { b }; + b = { a }; + a.b = b; + const variables = { + a, + b, + }; + execute(link, { query: sampleQuery, variables }).subscribe( + result => { + done.fail('next should have been thrown from the link'); + }, + makeCallback(done, e => { + expect(e.message).toMatch(/Payload is not serializable/); + expect(e.parseError.message).toMatch( + /Converting circular structure to JSON/, + ); + }), + ); + }); + it('supports being cancelled and does not throw', done => { + let called; + class AbortController { + signal: {}; + abort = () => { + called = true; + }; + } + + global.AbortController = AbortController; + + fetch.mockReturnValueOnce(Promise.resolve({ text })); + text.mockReturnValueOnce( + Promise.resolve('{ "data": { "hello": "world" } }'), + ); + + const link = createHttpLink({ uri: 'data', fetch }); + + const sub = execute(link, { query: sampleQuery }).subscribe({ + next: result => { + done.fail('result should not have been called'); + }, + error: e => { + done.fail(e); + }, + complete: () => { + done.fail('complete should not have been called'); + }, + }); + sub.unsubscribe(); + + setTimeout( + makeCallback(done, () => { + delete global.AbortController; + expect(called).toBe(true); + fetch.mockReset(); + text.mockReset(); + }), + 150, + ); + }); + + const body = '{'; + const unparsableJson = jest.fn(() => Promise.resolve(body)); + it('throws an error if response is unparsable', done => { + fetch.mockReturnValueOnce( + Promise.resolve({ status: 400, text: unparsableJson }), + ); + const link = createHttpLink({ uri: 'data', fetch }); + + execute(link, { query: sampleQuery }).subscribe( + result => { + done.fail('next should have been thrown from the network'); + }, + makeCallback(done, e => { + expect(e.message).toMatch(/JSON/); + expect(e.statusCode).toBe(400); + expect(e.response).toBeDefined(); + expect(e.bodyText).toBe(body); + }), + ); + }); + }); +}); diff --git a/src/link/http/__tests__/checkFetcher.ts b/src/link/http/__tests__/checkFetcher.ts new file mode 100644 index 00000000000..431c3b4905c --- /dev/null +++ b/src/link/http/__tests__/checkFetcher.ts @@ -0,0 +1,23 @@ +import { checkFetcher } from '../checkFetcher'; + +describe('checkFetcher', () => { + let oldFetch; + beforeEach(() => { + oldFetch = window.fetch; + delete window.fetch; + }); + + afterEach(() => { + window.fetch = oldFetch; + }); + + it('throws if no fetch is present', () => { + expect(() => checkFetcher(undefined)).toThrow( + /has not been found globally/, + ); + }); + + it('does not throws if no fetch is present but a fetch is passed', () => { + expect(() => checkFetcher(() => {})).not.toThrow(); + }); +}); diff --git a/src/link/http/__tests__/parseAndCheckHttpResponse.ts b/src/link/http/__tests__/parseAndCheckHttpResponse.ts new file mode 100644 index 00000000000..7670675483f --- /dev/null +++ b/src/link/http/__tests__/parseAndCheckHttpResponse.ts @@ -0,0 +1,91 @@ +import gql from 'graphql-tag'; +import fetchMock from 'fetch-mock'; + +import { createOperation } from '../../utils/createOperation'; +import { parseAndCheckHttpResponse } from '../parseAndCheckHttpResponse'; + +const query = gql` + query SampleQuery { + stub { + id + } + } +`; + +describe('parseAndCheckResponse', () => { + beforeEach(() => { + fetchMock.restore(); + }); + + const operations = [createOperation({}, { query })]; + + it('throws a parse error with a status code on unparsable response', done => { + const status = 400; + fetchMock.mock('begin:/error', status); + fetch('error') + .then(parseAndCheckHttpResponse(operations)) + .then(done.fail) + .catch(e => { + expect(e.statusCode).toBe(status); + expect(e.name).toBe('ServerParseError'); + expect(e).toHaveProperty('response'); + expect(e).toHaveProperty('bodyText'); + done(); + }) + .catch(done.fail); + }); + + it('throws a network error with a status code and result', done => { + const status = 403; + const body = { data: 'fail' }; //does not contain data or errors + fetchMock.mock('begin:/error', { + body, + status, + }); + fetch('error') + .then(parseAndCheckHttpResponse(operations)) + .then(done.fail) + .catch(e => { + expect(e.statusCode).toBe(status); + expect(e.name).toBe('ServerError'); + expect(e).toHaveProperty('response'); + expect(e).toHaveProperty('result'); + done(); + }) + .catch(done.fail); + }); + + it('throws a server error on incorrect data', done => { + const data = { hello: 'world' }; //does not contain data or erros + fetchMock.mock('begin:/incorrect', data); + fetch('incorrect') + .then(parseAndCheckHttpResponse(operations)) + .then(done.fail) + .catch(e => { + expect(e.statusCode).toBe(200); + expect(e.name).toBe('ServerError'); + expect(e).toHaveProperty('response'); + expect(e.result).toEqual(data); + done(); + }) + .catch(done.fail); + }); + + it('is able to return a correct GraphQL result', done => { + const errors = ['', '' + new Error('hi')]; + const data = { data: { hello: 'world' }, errors }; + + fetchMock.mock('begin:/data', { + body: data, + }); + fetch('data') + .then(parseAndCheckHttpResponse(operations)) + .then(({ data, errors: e }) => { + expect(data).toEqual({ hello: 'world' }); + expect(e.length).toEqual(errors.length); + expect(e).toEqual(errors); + done(); + }) + .catch(done.fail); + }); +}); diff --git a/src/link/http/__tests__/selectHttpOptionsAndBody.ts b/src/link/http/__tests__/selectHttpOptionsAndBody.ts new file mode 100644 index 00000000000..e9e7a538722 --- /dev/null +++ b/src/link/http/__tests__/selectHttpOptionsAndBody.ts @@ -0,0 +1,91 @@ +import gql from 'graphql-tag'; + +import { createOperation } from '../../utils/createOperation'; +import { + selectHttpOptionsAndBody, + fallbackHttpConfig, +} from '../selectHttpOptionsAndBody'; + +const query = gql` + query SampleQuery { + stub { + id + } + } +`; + +describe('selectHttpOptionsAndBody', () => { + it('includeQuery allows the query to be ignored', () => { + const { options, body } = selectHttpOptionsAndBody( + createOperation({}, { query }), + { http: { includeQuery: false } }, + ); + expect(body).not.toHaveProperty('query'); + }); + + it('includeExtensions allows the extensions to be added', () => { + const extensions = { yo: 'what up' }; + const { options, body } = selectHttpOptionsAndBody( + createOperation({}, { query, extensions }), + { http: { includeExtensions: true } }, + ); + expect(body).toHaveProperty('extensions'); + expect((body as any).extensions).toEqual(extensions); + }); + + it('the fallbackConfig is used if no other configs are specified', () => { + const defaultHeaders = { + accept: '*/*', + 'content-type': 'application/json', + }; + + const defaultOptions = { + method: 'POST', + }; + + const extensions = { yo: 'what up' }; + const { options, body } = selectHttpOptionsAndBody( + createOperation({}, { query, extensions }), + fallbackHttpConfig, + ); + + expect(body).toHaveProperty('query'); + expect(body).not.toHaveProperty('extensions'); + + expect(options.headers).toEqual(defaultHeaders); + expect(options.method).toEqual(defaultOptions.method); + }); + + it('allows headers, credentials, and setting of method to function correctly', () => { + const headers = { + accept: 'application/json', + 'content-type': 'application/graphql', + }; + + const credentials = { + 'X-Secret': 'djmashko', + }; + + const opts = { + opt: 'hi', + }; + + const config = { headers, credentials, options: opts }; + + const extensions = { yo: 'what up' }; + + const { options, body } = selectHttpOptionsAndBody( + createOperation({}, { query, extensions }), + fallbackHttpConfig, + config, + ); + + expect(body).toHaveProperty('query'); + expect(body).not.toHaveProperty('extensions'); + + expect(options.headers).toEqual(headers); + expect(options.credentials).toEqual(credentials); + expect(options.opt).toEqual('hi'); + expect(options.method).toEqual('POST'); //from default + }); +}); diff --git a/src/link/http/__tests__/selectURI.ts b/src/link/http/__tests__/selectURI.ts new file mode 100644 index 00000000000..5e5271c27d5 --- /dev/null +++ b/src/link/http/__tests__/selectURI.ts @@ -0,0 +1,32 @@ +import gql from 'graphql-tag'; + +import { createOperation } from '../../utils/createOperation'; +import { selectURI } from '../selectURI'; + +const query = gql` + query SampleQuery { + stub { + id + } + } +`; + +describe('selectURI', () => { + it('returns a passed in string', () => { + const uri = '/somewhere'; + const operation = createOperation({ uri }, { query }); + expect(selectURI(operation)).toEqual(uri); + }); + + it('returns a fallback of /graphql', () => { + const uri = '/graphql'; + const operation = createOperation({}, { query }); + expect(selectURI(operation)).toEqual(uri); + }); + + it('returns the result of a UriFunction', () => { + const uri = '/somewhere'; + const operation = createOperation({}, { query }); + expect(selectURI(operation, () => uri)).toEqual(uri); + }); +}); diff --git a/src/link/http/__tests__/serializeFetchParameter.ts b/src/link/http/__tests__/serializeFetchParameter.ts new file mode 100644 index 00000000000..b22dbf25156 --- /dev/null +++ b/src/link/http/__tests__/serializeFetchParameter.ts @@ -0,0 +1,17 @@ +import { serializeFetchParameter } from '../serializeFetchParameter'; + +describe('serializeFetchParameter', () => { + it('throws a parse error on an unparsable body', () => { + const b = {}; + const a = { b }; + (b as any).a = a; + + expect(() => serializeFetchParameter(b, 'Label')).toThrow(/Label/); + }); + + it('returns a correctly parsed body', () => { + const body = { no: 'thing' }; + + expect(serializeFetchParameter(body, 'Label')).toEqual('{"no":"thing"}'); + }); +}); diff --git a/src/link/http/checkFetcher.ts b/src/link/http/checkFetcher.ts new file mode 100644 index 00000000000..6dc594b80cd --- /dev/null +++ b/src/link/http/checkFetcher.ts @@ -0,0 +1,20 @@ +import { InvariantError } from 'ts-invariant'; + +export const checkFetcher = (fetcher: WindowOrWorkerGlobalScope['fetch']) => { + if (!fetcher && typeof fetch === 'undefined') { + let library: string = 'unfetch'; + if (typeof window === 'undefined') library = 'node-fetch'; + throw new InvariantError( + '"fetch" has not been found globally and no fetcher has been ' + + 'configured. To fix this, install a fetch package ' + + `(like https://www.npmjs.com/package/${library}), instantiate the ` + + 'fetcher, and pass it into your `HttpLink` constructor. For example:' + + '\n\n' + + `import fetch from '${library}';\n` + + "import { ApolloClient, HttpLink } from '@apollo/client';\n" + + 'const client = new ApolloClient({\n' + + " link: new HttpLink({ uri: '/graphq', fetch })\n" + + '});' + ); + } +}; diff --git a/src/link/http/createHttpLink.ts b/src/link/http/createHttpLink.ts new file mode 100644 index 00000000000..42281dc69af --- /dev/null +++ b/src/link/http/createHttpLink.ts @@ -0,0 +1,181 @@ +import { DefinitionNode } from 'graphql'; + +import { Observable } from '../../util/Observable'; +import { serializeFetchParameter } from './serializeFetchParameter'; +import { selectURI } from './selectURI'; +import { parseAndCheckHttpResponse } from './parseAndCheckHttpResponse'; +import { checkFetcher } from './checkFetcher'; +import { + selectHttpOptionsAndBody, + fallbackHttpConfig, + HttpOptions +} from './selectHttpOptionsAndBody'; +import { createSignalIfSupported } from './createSignalIfSupported'; +import { rewriteURIForGET } from './rewriteURIForGET'; +import { ApolloLink } from '../core'; +import { fromError } from '../utils/fromError'; + +export const createHttpLink = (linkOptions: HttpOptions = {}) => { + let { + uri = '/graphql', + // use default global fetch if nothing passed in + fetch: fetcher, + includeExtensions, + useGETForQueries, + ...requestOptions + } = linkOptions; + + // dev warnings to ensure fetch is present + checkFetcher(fetcher); + + //fetcher is set here rather than the destructuring to ensure fetch is + //declared before referencing it. Reference in the destructuring would cause + //a ReferenceError + if (!fetcher) { + fetcher = fetch; + } + + const linkConfig = { + http: { includeExtensions }, + options: requestOptions.fetchOptions, + credentials: requestOptions.credentials, + headers: requestOptions.headers, + }; + + return new ApolloLink(operation => { + let chosenURI = selectURI(operation, uri); + + const context = operation.getContext(); + + // `apollographql-client-*` headers are automatically set if a + // `clientAwareness` object is found in the context. These headers are + // set first, followed by the rest of the headers pulled from + // `context.headers`. If desired, `apollographql-client-*` headers set by + // the `clientAwareness` object can be overridden by + // `apollographql-client-*` headers set in `context.headers`. + const clientAwarenessHeaders: { + 'apollographql-client-name'?: string; + 'apollographql-client-version'?: string; + } = {}; + + if (context.clientAwareness) { + const { name, version } = context.clientAwareness; + if (name) { + clientAwarenessHeaders['apollographql-client-name'] = name; + } + if (version) { + clientAwarenessHeaders['apollographql-client-version'] = version; + } + } + + const contextHeaders = { ...clientAwarenessHeaders, ...context.headers }; + + const contextConfig = { + http: context.http, + options: context.fetchOptions, + credentials: context.credentials, + headers: contextHeaders, + }; + + //uses fallback, link, and then context to build options + const { options, body } = selectHttpOptionsAndBody( + operation, + fallbackHttpConfig, + linkConfig, + contextConfig, + ); + + let controller: any; + if (!(options as any).signal) { + const { controller: _controller, signal } = createSignalIfSupported(); + controller = _controller; + if (controller) (options as any).signal = signal; + } + + // If requested, set method to GET if there are no mutations. + const definitionIsMutation = (d: DefinitionNode) => { + return d.kind === 'OperationDefinition' && d.operation === 'mutation'; + }; + if ( + useGETForQueries && + !operation.query.definitions.some(definitionIsMutation) + ) { + options.method = 'GET'; + } + + if (options.method === 'GET') { + const { newURI, parseError } = rewriteURIForGET(chosenURI, body); + if (parseError) { + return fromError(parseError); + } + chosenURI = newURI; + } else { + try { + (options as any).body = serializeFetchParameter(body, 'Payload'); + } catch (parseError) { + return fromError(parseError); + } + } + + return new Observable(observer => { + fetcher(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); + } + observer.error(err); + }); + + return () => { + // XXX support canceling this request + // https://developers.google.com/web/updates/2017/09/abortable-fetch + if (controller) controller.abort(); + }; + }); + }); +}; diff --git a/src/link/http/createSignalIfSupported.ts b/src/link/http/createSignalIfSupported.ts new file mode 100644 index 00000000000..560e8a92645 --- /dev/null +++ b/src/link/http/createSignalIfSupported.ts @@ -0,0 +1,8 @@ +export const createSignalIfSupported = () => { + if (typeof AbortController === 'undefined') + return { controller: false, signal: false }; + + const controller = new AbortController(); + const signal = controller.signal; + return { controller, signal }; +}; diff --git a/src/link/http/index.ts b/src/link/http/index.ts new file mode 100644 index 00000000000..2dde558a321 --- /dev/null +++ b/src/link/http/index.ts @@ -0,0 +1,26 @@ +export { + parseAndCheckHttpResponse, + ServerParseError +} from './parseAndCheckHttpResponse'; + +export { + serializeFetchParameter, + ClientParseError +} from './serializeFetchParameter'; + +export { + HttpOptions, + fallbackHttpConfig, + selectHttpOptionsAndBody, + UriFunction +} from './selectHttpOptionsAndBody'; + +export { checkFetcher } from './checkFetcher'; + +export { createSignalIfSupported } from './createSignalIfSupported'; + +export { selectURI } from './selectURI'; + +export { createHttpLink } from './createHttpLink'; + +export { HttpLink } from './HttpLink'; diff --git a/src/link/http/parseAndCheckHttpResponse.ts b/src/link/http/parseAndCheckHttpResponse.ts new file mode 100644 index 00000000000..96910eecd74 --- /dev/null +++ b/src/link/http/parseAndCheckHttpResponse.ts @@ -0,0 +1,57 @@ +import { Operation } from '../core'; +import { throwServerError } from '../utils/throwServerError'; + +const { hasOwnProperty } = Object.prototype; + +export type ServerParseError = Error & { + response: Response; + statusCode: number; + bodyText: string; +}; + +export function parseAndCheckHttpResponse( + operations: Operation | Operation[], +) { + 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; + } + }) + .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/rewriteURIForGET.ts b/src/link/http/rewriteURIForGET.ts new file mode 100644 index 00000000000..c652183e67b --- /dev/null +++ b/src/link/http/rewriteURIForGET.ts @@ -0,0 +1,62 @@ +import { serializeFetchParameter } from './serializeFetchParameter'; +import { Body } from './selectHttpOptionsAndBody'; + +// For GET operations, returns the given URI rewritten with parameters, or a +// parse error. +export function rewriteURIForGET(chosenURI: string, body: Body) { + // Implement the standard HTTP GET serialization, plus 'extensions'. Note + // the extra level of JSON serialization! + const queryParams: string[] = []; + const addQueryParam = (key: string, value: string) => { + queryParams.push(`${key}=${encodeURIComponent(value)}`); + }; + + if ('query' in body) { + addQueryParam('query', body.query); + } + if (body.operationName) { + addQueryParam('operationName', body.operationName); + } + if (body.variables) { + let serializedVariables; + try { + serializedVariables = serializeFetchParameter( + body.variables, + 'Variables map', + ); + } catch (parseError) { + return { parseError }; + } + addQueryParam('variables', serializedVariables); + } + if (body.extensions) { + let serializedExtensions; + try { + serializedExtensions = serializeFetchParameter( + body.extensions, + 'Extensions map', + ); + } catch (parseError) { + return { parseError }; + } + addQueryParam('extensions', serializedExtensions); + } + + // Reconstruct the URI with added query params. + // XXX This assumes that the URI is well-formed and that it doesn't + // already contain any of these query params. We could instead use the + // URL API and take a polyfill (whatwg-url@6) for older browsers that + // don't support URLSearchParams. Note that some browsers (and + // versions of whatwg-url) support URL but not URLSearchParams! + let fragment = '', + preFragment = chosenURI; + const fragmentStart = chosenURI.indexOf('#'); + if (fragmentStart !== -1) { + fragment = chosenURI.substr(fragmentStart); + preFragment = chosenURI.substr(0, fragmentStart); + } + const queryParamsPrefix = preFragment.indexOf('?') === -1 ? '?' : '&'; + const newURI = + preFragment + queryParamsPrefix + queryParams.join('&') + fragment; + return { newURI }; +} diff --git a/src/link/http/selectHttpOptionsAndBody.ts b/src/link/http/selectHttpOptionsAndBody.ts new file mode 100644 index 00000000000..77b3e1641c3 --- /dev/null +++ b/src/link/http/selectHttpOptionsAndBody.ts @@ -0,0 +1,138 @@ +import { print } from 'graphql/language/printer'; + +import { Operation } from '../core'; + +export interface UriFunction { + (operation: Operation): string; +} + +export interface Body { + query?: string; + operationName?: string; + variables?: Record; + extensions?: Record; +} + +export interface HttpOptions { + /** + * The URI to use when fetching operations. + * + * Defaults to '/graphql'. + */ + uri?: string | UriFunction; + + /** + * Passes the extensions field to your graphql server. + * + * Defaults to false. + */ + includeExtensions?: boolean; + + /** + * A `fetch`-compatible API to use when making requests. + */ + fetch?: WindowOrWorkerGlobalScope['fetch']; + + /** + * An object representing values to be sent as headers on the request. + */ + headers?: any; + + /** + * The credentials policy you want to use for the fetch call. + */ + credentials?: string; + + /** + * Any overrides of the fetch options argument to pass to the fetch call. + */ + fetchOptions?: any; + + /** + * If set to true, use the HTTP GET method for query operations. Mutations + * will still use the method specified in fetchOptions.method (which defaults + * to POST). + */ + useGETForQueries?: boolean; +} + +export interface HttpQueryOptions { + includeQuery?: boolean; + includeExtensions?: boolean; +} + +export interface HttpConfig { + http?: HttpQueryOptions; + options?: any; + headers?: any; + credentials?: any; +} + +const defaultHttpOptions: HttpQueryOptions = { + includeQuery: true, + includeExtensions: false, +}; + +const defaultHeaders = { + // headers are case insensitive (https://stackoverflow.com/a/5259004) + accept: '*/*', + 'content-type': 'application/json', +}; + +const defaultOptions = { + method: 'POST', +}; + +export const fallbackHttpConfig = { + http: defaultHttpOptions, + headers: defaultHeaders, + options: defaultOptions, +}; + +export const selectHttpOptionsAndBody = ( + operation: Operation, + fallbackConfig: HttpConfig, + ...configs: Array +) => { + let options: HttpConfig & Record = { + ...fallbackConfig.options, + headers: fallbackConfig.headers, + credentials: fallbackConfig.credentials, + }; + let http: HttpQueryOptions = fallbackConfig.http; + + /* + * use the rest of the configs to populate the options + * configs later in the list will overwrite earlier fields + */ + configs.forEach(config => { + options = { + ...options, + ...config.options, + headers: { + ...options.headers, + ...config.headers, + }, + }; + if (config.credentials) options.credentials = config.credentials; + + http = { + ...http, + ...config.http, + }; + }); + + //The body depends on the http options + const { operationName, extensions, variables, query } = operation; + const body: Body = { operationName, variables }; + + if (http.includeExtensions) (body as any).extensions = extensions; + + // not sending the query (i.e persisted queries) + if (http.includeQuery) (body as any).query = print(query); + + return { + options, + body, + }; +}; diff --git a/src/link/http/selectURI.ts b/src/link/http/selectURI.ts new file mode 100644 index 00000000000..461098f2e2e --- /dev/null +++ b/src/link/http/selectURI.ts @@ -0,0 +1,17 @@ +import { Operation } from '../core'; + +export const selectURI = ( + operation: Operation, + fallbackURI?: string | ((operation: Operation) => string), +) => { + const context = operation.getContext(); + const contextURI = context.uri; + + if (contextURI) { + return contextURI; + } else if (typeof fallbackURI === 'function') { + return fallbackURI(operation); + } else { + return (fallbackURI as string) || '/graphql'; + } +}; diff --git a/src/link/http/serializeFetchParameter.ts b/src/link/http/serializeFetchParameter.ts new file mode 100644 index 00000000000..f127e36b5b3 --- /dev/null +++ b/src/link/http/serializeFetchParameter.ts @@ -0,0 +1,19 @@ +import { InvariantError } from 'ts-invariant'; + +export type ClientParseError = InvariantError & { + parseError: Error; +}; + +export const serializeFetchParameter = (p: any, label: string) => { + let serialized; + try { + serialized = JSON.stringify(p); + } catch (e) { + const parseError = new InvariantError( + `Network request failed. ${label} is not serializable: ${e.message}`, + ) as ClientParseError; + parseError.parseError = e; + throw parseError; + } + return serialized; +}; diff --git a/src/link/utils/__tests__/fromError.ts b/src/link/utils/__tests__/fromError.ts new file mode 100644 index 00000000000..4bae95ca74f --- /dev/null +++ b/src/link/utils/__tests__/fromError.ts @@ -0,0 +1,12 @@ +import { toPromise } from '../toPromise'; +import { fromError, } from '../fromError'; + +describe('fromError', () => { + it('acts as error call', () => { + const error = new Error('I always error'); + const observable = fromError(error); + return toPromise(observable) + .then(expect.fail) + .catch(actualError => expect(error).toEqual(actualError)); + }); +}); diff --git a/src/link/utils/__tests__/fromPromise.ts b/src/link/utils/__tests__/fromPromise.ts new file mode 100644 index 00000000000..4849f040828 --- /dev/null +++ b/src/link/utils/__tests__/fromPromise.ts @@ -0,0 +1,25 @@ +import { fromPromise } from '../fromPromise'; +import { toPromise, } from '../toPromise'; + +describe('fromPromise', () => { + const data = { + data: { + hello: 'world', + }, + }; + const error = new Error('I always error'); + + it('return next call as Promise resolution', () => { + const observable = fromPromise(Promise.resolve(data)); + return toPromise(observable).then(result => + expect(data).toEqual(result), + ); + }); + + it('return Promise rejection as error call', () => { + const observable = fromPromise(Promise.reject(error)); + return toPromise(observable) + .then(expect.fail) + .catch(actualError => expect(error).toEqual(actualError)); + }); +}); diff --git a/src/link/utils/__tests__/toPromise.ts b/src/link/utils/__tests__/toPromise.ts new file mode 100644 index 00000000000..9fc6a5a842d --- /dev/null +++ b/src/link/utils/__tests__/toPromise.ts @@ -0,0 +1,46 @@ +import { Observable } from '../../../util/Observable'; +import { toPromise } from '../toPromise'; +import { fromError } from '../fromError'; + +describe('toPromise', () => { + const data = { + data: { + hello: 'world', + }, + }; + const error = new Error('I always error'); + + it('return next call as Promise resolution', () => { + return toPromise(Observable.of(data)).then(result => + expect(data).toEqual(result), + ); + }); + + it('return error call as Promise rejection', () => { + return toPromise(fromError(error)) + .then(expect.fail) + .catch(actualError => expect(error).toEqual(actualError)); + }); + + describe('warnings', () => { + const spy = jest.fn(); + let _warn: (message?: any, ...originalParams: any[]) => void; + + beforeEach(() => { + _warn = console.warn; + console.warn = spy; + }); + + afterEach(() => { + console.warn = _warn; + }); + + it('return error call as Promise rejection', done => { + toPromise(Observable.of(data, data)).then(result => { + expect(data).toEqual(result); + expect(spy).toHaveBeenCalled(); + done(); + }); + }); + }); +}); diff --git a/src/link/utils/__tests__/validateOperation.ts b/src/link/utils/__tests__/validateOperation.ts new file mode 100644 index 00000000000..941892fd4e4 --- /dev/null +++ b/src/link/utils/__tests__/validateOperation.ts @@ -0,0 +1,17 @@ +import { validateOperation, } from '../validateOperation'; + +describe('validateOperation', () => { + it('should throw when invalid field in operation', () => { + expect(() => validateOperation({ qwerty: '' })).toThrow(); + }); + + it('should not throw when valid fields in operation', () => { + expect(() => + validateOperation({ + query: '1234', + context: {}, + variables: {}, + }), + ).not.toThrow(); + }); +}); diff --git a/src/link/utils/createOperation.ts b/src/link/utils/createOperation.ts new file mode 100644 index 00000000000..dba0c410b09 --- /dev/null +++ b/src/link/utils/createOperation.ts @@ -0,0 +1,28 @@ +import { GraphQLRequest, Operation } from '../core/types'; + +export function createOperation( + starting: any, + operation: GraphQLRequest, +): Operation { + let context = { ...starting }; + const setContext = (next: any) => { + if (typeof next === 'function') { + context = { ...context, ...next(context) }; + } else { + context = { ...context, ...next }; + } + }; + const getContext = () => ({ ...context }); + + Object.defineProperty(operation, 'setContext', { + enumerable: false, + value: setContext, + }); + + Object.defineProperty(operation, 'getContext', { + enumerable: false, + value: getContext, + }); + + return operation as Operation; +} diff --git a/src/link/utils/fromError.ts b/src/link/utils/fromError.ts new file mode 100644 index 00000000000..e6d2c52ece3 --- /dev/null +++ b/src/link/utils/fromError.ts @@ -0,0 +1,7 @@ +import { Observable } from '../../util/Observable'; + +export function fromError(errorValue: any): Observable { + return new Observable(observer => { + observer.error(errorValue); + }); +} diff --git a/src/link/utils/fromPromise.ts b/src/link/utils/fromPromise.ts new file mode 100644 index 00000000000..9ec31fd675f --- /dev/null +++ b/src/link/utils/fromPromise.ts @@ -0,0 +1,12 @@ +import { Observable } from '../../util/Observable'; + +export function fromPromise(promise: Promise): Observable { + return new Observable(observer => { + promise + .then((value: T) => { + observer.next(value); + observer.complete(); + }) + .catch(observer.error.bind(observer)); + }); +} diff --git a/src/link/utils/index.ts b/src/link/utils/index.ts new file mode 100644 index 00000000000..d7998382661 --- /dev/null +++ b/src/link/utils/index.ts @@ -0,0 +1,2 @@ +export { fromError } from './fromError'; +export { ServerError, throwServerError } from './throwServerError'; diff --git a/src/link/utils/throwServerError.ts b/src/link/utils/throwServerError.ts new file mode 100644 index 00000000000..b9283d16cd3 --- /dev/null +++ b/src/link/utils/throwServerError.ts @@ -0,0 +1,18 @@ +export type ServerError = Error & { + response: Response; + result: Record; + statusCode: number; +}; + +export const throwServerError = ( + response: Response, + result: any, + message: string +) => { + const error = new Error(message) as ServerError; + error.name = 'ServerError'; + error.response = response; + error.statusCode = response.status; + error.result = result; + throw error; +}; diff --git a/src/link/utils/toPromise.ts b/src/link/utils/toPromise.ts new file mode 100644 index 00000000000..13d9fcc5353 --- /dev/null +++ b/src/link/utils/toPromise.ts @@ -0,0 +1,22 @@ +import { invariant } from 'ts-invariant'; + +import { Observable } from '../../util/Observable'; + +export function toPromise(observable: Observable): Promise { + let completed = false; + return new Promise((resolve, reject) => { + observable.subscribe({ + next: data => { + if (completed) { + invariant.warn( + `Promise Wrapper does not support multiple results from Observable`, + ); + } else { + completed = true; + resolve(data); + } + }, + error: reject, + }); + }); +} diff --git a/src/link/utils/transformOperation.ts b/src/link/utils/transformOperation.ts new file mode 100644 index 00000000000..7fde936c120 --- /dev/null +++ b/src/link/utils/transformOperation.ts @@ -0,0 +1,21 @@ +import { GraphQLRequest, Operation } from '../core/types'; +import { getOperationName } from '../../utilities/getFromAST'; + +export function transformOperation(operation: GraphQLRequest): GraphQLRequest { + const transformedOperation: GraphQLRequest = { + variables: operation.variables || {}, + extensions: operation.extensions || {}, + operationName: operation.operationName, + query: operation.query, + }; + + // Best guess at an operation name + if (!transformedOperation.operationName) { + transformedOperation.operationName = + typeof transformedOperation.query !== 'string' + ? getOperationName(transformedOperation.query) + : ''; + } + + return transformedOperation as Operation; +} diff --git a/src/link/utils/validateOperation.ts b/src/link/utils/validateOperation.ts new file mode 100644 index 00000000000..e04fb46ddb1 --- /dev/null +++ b/src/link/utils/validateOperation.ts @@ -0,0 +1,20 @@ +import { InvariantError } from 'ts-invariant'; + +import { GraphQLRequest } from '../core/types'; + +export function validateOperation(operation: GraphQLRequest): GraphQLRequest { + const OPERATION_FIELDS = [ + 'query', + 'operationName', + 'variables', + 'extensions', + 'context', + ]; + for (let key of Object.keys(operation)) { + if (OPERATION_FIELDS.indexOf(key) < 0) { + throw new InvariantError(`illegal argument: ${key}`); + } + } + + return operation; +} diff --git a/src/react/context/__tests__/ApolloConsumer.test.tsx b/src/react/context/__tests__/ApolloConsumer.test.tsx index f3f8ed35474..dc0a6875ae9 100644 --- a/src/react/context/__tests__/ApolloConsumer.test.tsx +++ b/src/react/context/__tests__/ApolloConsumer.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { ApolloLink } from 'apollo-link'; import { render, cleanup } from '@testing-library/react'; +import { ApolloLink } from '../../../link/core'; import ApolloClient from '../../../ApolloClient'; import { InMemoryCache as Cache } from '../../../cache/inmemory/inMemoryCache'; import { ApolloProvider } from '../ApolloProvider'; diff --git a/src/react/context/__tests__/ApolloProvider.test.tsx b/src/react/context/__tests__/ApolloProvider.test.tsx index 2a10fa6bb1f..c16ad4780fa 100644 --- a/src/react/context/__tests__/ApolloProvider.test.tsx +++ b/src/react/context/__tests__/ApolloProvider.test.tsx @@ -1,7 +1,7 @@ import React, { useContext } from 'react'; import { render, cleanup } from '@testing-library/react'; -import { ApolloLink } from 'apollo-link'; +import { ApolloLink } from '../../../link/core'; import ApolloClient from '../../../ApolloClient'; import { InMemoryCache as Cache } from '../../../cache/inmemory/inMemoryCache'; import { ApolloProvider } from '../ApolloProvider'; diff --git a/src/react/data/MutationData.ts b/src/react/data/MutationData.ts index 226dfa809db..ca8d512b000 100644 --- a/src/react/data/MutationData.ts +++ b/src/react/data/MutationData.ts @@ -6,12 +6,12 @@ import { ApolloError } from '../../errors/ApolloError'; import { MutationOptions, MutationTuple, - ExecutionResult, MutationFunctionOptions, MutationResult } from '../types/types'; import { OperationData } from './OperationData'; import { OperationVariables } from '../../core/types'; +import { FetchResult } from '../../link/core'; export class MutationData< TData = any, @@ -66,7 +66,7 @@ export class MutationData< const mutationId = this.generateNewMutationId(); return this.mutate(mutationFunctionOptions) - .then((response: ExecutionResult) => { + .then((response: FetchResult) => { this.onMutationCompleted(response, mutationId); return response; }) @@ -123,7 +123,7 @@ export class MutationData< } private onMutationCompleted( - response: ExecutionResult, + response: FetchResult, mutationId: number ) { const { onCompleted, ignoreResults } = this.getOptions(); diff --git a/src/react/hooks/__tests__/useApolloClient.test.tsx b/src/react/hooks/__tests__/useApolloClient.test.tsx index 63b818225ab..65a5e13921a 100644 --- a/src/react/hooks/__tests__/useApolloClient.test.tsx +++ b/src/react/hooks/__tests__/useApolloClient.test.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { render, cleanup } from '@testing-library/react'; -import { ApolloLink } from 'apollo-link'; import { InvariantError } from 'ts-invariant'; +import { ApolloLink } from '../../../link/core'; import { ApolloProvider } from '../../context/ApolloProvider'; import { resetApolloContext } from '../../context/ApolloContext'; import ApolloClient from '../../../ApolloClient'; diff --git a/src/react/hooks/__tests__/useQuery.test.tsx b/src/react/hooks/__tests__/useQuery.test.tsx index 4cc3b094b95..e7906957761 100644 --- a/src/react/hooks/__tests__/useQuery.test.tsx +++ b/src/react/hooks/__tests__/useQuery.test.tsx @@ -2,8 +2,9 @@ import React, { useState, useReducer } from 'react'; import { DocumentNode, GraphQLError } from 'graphql'; import gql from 'graphql-tag'; import { render, cleanup, wait } from '@testing-library/react'; -import { ApolloLink, Observable } from 'apollo-link'; +import { Observable } from '../../../util/Observable'; +import { ApolloLink } from '../../../link/core'; import { MockedProvider, MockLink } from '../../testing'; import ApolloClient from '../../../ApolloClient'; import { InMemoryCache } from '../../../cache/inmemory/inMemoryCache'; diff --git a/src/react/testing/mocks/mockLink.ts b/src/react/testing/mocks/mockLink.ts index f1e75f3f405..82390cdabd9 100644 --- a/src/react/testing/mocks/mockLink.ts +++ b/src/react/testing/mocks/mockLink.ts @@ -1,13 +1,13 @@ +import { print } from 'graphql/language/printer'; +import stringify from 'fast-json-stable-stringify'; + +import { Observable } from '../../../util/Observable'; import { Operation, GraphQLRequest, ApolloLink, FetchResult, - Observable -} from 'apollo-link'; -import { print } from 'graphql/language/printer'; -import stringify from 'fast-json-stable-stringify'; - +} from '../../../link/core'; import { addTypenameToDocument, removeClientSetsFromDocument, diff --git a/src/react/testing/mocks/mockSubscriptionLink.ts b/src/react/testing/mocks/mockSubscriptionLink.ts index 0fc62afdbc4..77901105c66 100644 --- a/src/react/testing/mocks/mockSubscriptionLink.ts +++ b/src/react/testing/mocks/mockSubscriptionLink.ts @@ -1,5 +1,5 @@ -import { ApolloLink, FetchResult, Observable } from 'apollo-link'; - +import { Observable } from '../../../util/Observable'; +import { ApolloLink, FetchResult } from '../../../link/core'; import { MockedSubscriptionResult } from './types'; export class MockSubscriptionLink extends ApolloLink { diff --git a/src/react/testing/mocks/types.ts b/src/react/testing/mocks/types.ts index 4b95df0acb5..f32e312ea22 100644 --- a/src/react/testing/mocks/types.ts +++ b/src/react/testing/mocks/types.ts @@ -1,5 +1,4 @@ -import { ApolloLink, GraphQLRequest, FetchResult } from 'apollo-link'; - +import { ApolloLink, GraphQLRequest, FetchResult } from '../../../link/core'; import ApolloClient, { DefaultOptions } from '../../../ApolloClient'; import { Resolvers } from '../../../core/types'; import { ApolloCache } from '../../../cache/core/cache'; diff --git a/src/react/types/types.ts b/src/react/types/types.ts index bb1a8242ebd..056fd3c7a6e 100644 --- a/src/react/types/types.ts +++ b/src/react/types/types.ts @@ -1,7 +1,8 @@ import { ReactNode } from 'react'; -import { DocumentNode, GraphQLError } from 'graphql'; -import { Observable, FetchResult } from 'apollo-link'; +import { DocumentNode } from 'graphql'; +import { Observable } from '../../util/Observable'; +import { FetchResult } from '../../link/core'; import ApolloClient from '../../ApolloClient'; import { ApolloQueryResult, @@ -23,12 +24,6 @@ import { NetworkStatus } from '../../core/networkStatus'; export type Context = Record; -export interface ExecutionResult> { - data?: T; - extensions?: Record; - errors?: GraphQLError[]; -} - export type CommonOptions = TOptions & { client?: ApolloClient; }; @@ -201,7 +196,7 @@ export interface MutationOptions export type MutationTuple = [ ( options?: MutationFunctionOptions - ) => Promise>, + ) => Promise>, MutationResult ]; diff --git a/src/util/Observable.ts b/src/util/Observable.ts index f4a7a9707e8..327a92cb73e 100644 --- a/src/util/Observable.ts +++ b/src/util/Observable.ts @@ -1,19 +1,21 @@ -// This simplified polyfill attempts to follow the ECMAScript Observable proposal. -// See https://github.com/zenparsing/es-observable -import { Observable as LinkObservable } from 'apollo-link'; +import Observable from 'zen-observable'; + +// This simplified polyfill attempts to follow the ECMAScript Observable +// proposal (https://github.com/zenparsing/es-observable) +import 'symbol-observable'; export type Subscription = ZenObservable.Subscription; export type Observer = ZenObservable.Observer; -import $$observable from 'symbol-observable'; - -// rxjs interopt -export class Observable extends LinkObservable { - public [$$observable]() { - return this; - } - - public ['@@observable' as any]() { - return this; +// Use global module augmentation to add RxJS interop functionality. By +// using this approach (instead of subclassing `Observable` and adding an +// ['@@observable']() method), we ensure the exported `Observable` retains all +// existing type declarations from `@types/zen-observable` (which is important +// for projects like `apollo-link`). +declare global { + interface Observable { + ['@@observable'](): Observable; } } +(Observable.prototype as any)['@@observable'] = function () { return this; }; +export { Observable };