Repository: FormidableLabs/urql
Pull request #533 (2020-02-11 12:05:39)

Provide possibility to mask the __typename in client results
## Summary

It solves problems where you don't want to manually strip in variables/useQuery result before sending it off somewhere

## Set of changes

- add export to urql/core (maskTypename)
- adds the option to client to enable this on all results


changeset-bot[bot]: ###  🦋  Changeset is good to go

Latest commit: 1e6c974da9bcbcd69c49436631f0c9aa9d51be25

**We got this.**

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/FormidableLabs/urql/new/feat/strip-typename?filename=.changeset/eleven-coins-love.md&value=---%0A%22%40fake-scope%2Ffake-pkg%22%3A%20patch%0A---%0A%0AUpdate%20the%20package%20info%20above%20to%20the%20changes%20you%20want%2C%20and%20write%20your%20description%20here.%20You%20can%20add%20more%20packages%20on%20new%20lines.%0A)


codesandbox[bot]: This pull request is automatically built and testable in [CodeSandbox](https://codesandbox.io).

  To see build info of the built libraries, click [here](https://ci.codesandbox.io/status/FormidableLabs/urql/pr/533/builds/10606) or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 1e6c974da9bcbcd69c49436631f0c9aa9d51be25:

|Sandbox| Source |
|--|--|
|[runtime-monad-nl15o](https://codesandbox.io/s/runtime-monad-nl15o)| Configuration |

JoviDeCroock: > Doesn't it conceptually make more sense to be able to use `stripTypename` on `variables`?

So to me it makes sense being able to use it on the response for your query since we implicitly add `__typename`. This allows us to reverse that (optionally). `variables` with `__typename` seems like a bad practice for a request so I enforced it there? What would you change?
kitten: @JoviDeCroock That's true. What I'm unsure of is that we basically have an asymmetry already:

- We add `__typename` but that cannot be disabled or reversed on query documents
- We may remove `__typename` but that's on a hook level in this PR
- Removing `__typename` is often relevant when passing results as inputs but we'd be applying it to results instead of to variables

So I'm not sure whether the place where we're applying this makes sense?
JoviDeCroock: The main point you are hinting at is to keep it exclusively to `useRequest` right? I just think it's very ergonomic to provide this to `useQuery` in case of for instance `initialValues` of a form, when using object schema validation additional properties often result in errors.
marcusdarmstrong: The implication of these changes would be that it's impossible to useQuery to fetch __typename as a part of the original query? That seems harmful.
JoviDeCroock: > The implication of these changes would be that it's impossible to useQuery to fetch __typename as a part of the original query? That seems harmful.

No, we've always implicitly added `__typename` and result.data.field.__typename will still be defined it just won't be enumerable anymore
marcusdarmstrong: > > The implication of these changes would be that it's impossible to useQuery to fetch __typename as a part of the original query? That seems harmful.
> 
> No, we've always implicitly added `__typename` and result.data.field.__typename will still be defined it just won't be enumerable anymore

1. `__typename` is only automatically added via cache exchanges presently, not urql itself—our usage doesn't involve that automatic addition, as we don't use the default cache or graphcache—and that's actually important for us, as we'd like to move toward not actually having query ASTs on the client/in our bundles.
2. Enumerability is the expectation for fields I request in my query. That makes this a breaking change.
kitten: What this PR doesn't include yet is that this should and will only be enabled when an option on the client is enabled, e.g. `new Client({ maskTypenames: true })`

Patch:
diff --git a/.changeset/modern-queens-run.md b/.changeset/modern-queens-run.md
new file mode 100644
index 00000000..396de599
--- /dev/null
+++ b/.changeset/modern-queens-run.md
@@ -0,0 +1,7 @@
+---
+'@urql/core': minor
+---
+
+Adds the `maskTypename` export to urql-core, this deeply masks typenames from the given payload.
+Masking `__typename` properties is also available as a `maskTypename` option on the `Client`. Setting this to true will
+strip typenames from results.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 717d336b..aa34b461 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -111,6 +111,16 @@ yarn
 
 ## How do I add a new package?
 
+First of all we need to know where to put the package.
+
+- Exchanges should be added to `exchanges/` and the folder should be the plain
+  name of the exchange. Since the `package.json:name` is following the convention
+  of `@urql/exchange-*` the folder should just be without this conventional prefix.
+- All other packages should be added to `packages/`. Typically all packages should
+  be named `@urql/*` and their folders should be named exactly this without the
+  prefix or `*-urql`. Optionally if the package will be named `*-urql` then the folder
+  can take on the same name.
+
 When adding a new package, start by copying a `package.json` file from another project.
 You may want to alter the following fields first:
 
@@ -130,17 +140,16 @@ an appropriate extension (`.esm.js` for `module` and `.cjs.js` for `main`).
 
 If your entrypoint won't be at `src/index.ts` you may alter it. But the `types` field has to match
 the same file relative to the `dist/types` folder, where `rollup` will output the TypeScript
-declaration files. When setting up your package make sure to create a `src/index.ts` file (or a file
-at what you've set `source` to)
+declaration files.
+
+When setting up your package make sure to create a `src/index.ts` file
+(or any other file which you've pointed `package.json:source` to). Also don't forget to
+copy over the `tsconfig.json` from another package (You won't need to change it).
 
 The `scripts.prepare` task is set up to check your new `package.json` file for correctness. So in
 case you get anything wrong, you'll get a short error when running `yarn` after setting your new
 project up. Just in case! 😄
 
-Lastly, your new package will need to be added to the `tsconfig.json` in the root of the repository.
-Add a new entry to `compilerOptions.paths` where the key is the `name` you've used in your
-`package.json` and the value is an array with a single entry, the path to your package + `src/`.
-
 Afterwards you can check whether everything is working correctly by running:
 
 ```sh
diff --git a/exchanges/graphcache/tsconfig.json b/exchanges/graphcache/tsconfig.json
new file mode 100644
index 00000000..cd80aea8
--- /dev/null
+++ b/exchanges/graphcache/tsconfig.json
@@ -0,0 +1,12 @@
+{
+  "extends": "../../tsconfig.json",
+  "include": ["src"],
+  "compilerOptions": {
+    "baseUrl": "./",
+    "paths": {
+      "urql": ["../../node_modules/urql/src"],
+      "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/*": ["../../node_modules/@urql/*/src"]
+    }
+  }
+}
diff --git a/package.json b/package.json
index 2073ad07..fefefcb8 100644
--- a/package.json
+++ b/package.json
@@ -64,6 +64,7 @@
     "eslint-plugin-react": "^7.18.0",
     "eslint-plugin-react-hooks": "^2.3.0",
     "execa": "^4.0.0",
+    "glob": "^7.1.6",
     "graphql": "^14.6.0",
     "graphql-tag": "^2.10.1",
     "husky": "^4.2.1",
diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts
index 0ae87d7e..a2829c3b 100755
--- a/packages/core/src/client.ts
+++ b/packages/core/src/client.ts
@@ -16,6 +16,7 @@ import {
   switchMap,
   publish,
   subscribe,
+  map,
 } from 'wonka';
 
 import {
@@ -35,7 +36,7 @@ import {
   PromisifiedSource,
 } from './types';
 
-import { createRequest, toSuspenseSource, withPromise } from './utils';
+import { createRequest, toSuspenseSource, withPromise, maskTypename } from './utils';
 import { DocumentNode } from 'graphql';
 
 /** Options for configuring the URQL [client]{@link Client}. */
@@ -54,6 +55,8 @@ export interface ClientOptions {
   requestPolicy?: RequestPolicy;
   /** Use HTTP GET for queries. */
   preferGetMethod?: boolean;
+  /** Mask __typename from results. */
+  maskTypename?: boolean;
 }
 
 interface ActiveOperations {
@@ -72,6 +75,7 @@ export class Client {
   suspense: boolean;
   preferGetMethod: boolean;
   requestPolicy: RequestPolicy;
+  maskTypename: boolean;
 
   // These are internals to be used to keep track of operations
   dispatchOperation: (operation: Operation) => void;
@@ -90,6 +94,7 @@ export class Client {
     this.suspense = !!opts.suspense;
     this.requestPolicy = opts.requestPolicy || 'cache-first';
     this.preferGetMethod = !!opts.preferGetMethod;
+    this.maskTypename = !!opts.maskTypename;
 
     // This subject forms the input of operations; executeOperation may be
     // called to dispatch a new operation on the subject
@@ -182,11 +187,21 @@ export class Client {
   /** Executes an Operation by sending it through the exchange pipeline It returns an observable that emits all related exchange results and keeps track of this observable's subscribers. A teardown signal will be emitted when no subscribers are listening anymore. */
   executeRequestOperation(operation: Operation): Source<OperationResult> {
     const { key, operationName } = operation;
-    const operationResults$ = pipe(
+    let operationResults$ = pipe(
       this.results$,
       filter((res: OperationResult) => res.operation.key === key)
     );
 
+    if (this.maskTypename) {
+      operationResults$ = pipe(
+        operationResults$,
+        map(res => {
+          res.data = maskTypename(res.data);
+          return res;
+        }),
+      );
+    }
+
     if (operationName === 'mutation') {
       // A mutation is always limited to just a single result and is never shared
       return pipe(
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 7dad29c1..0c631ea4 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -9,4 +9,5 @@ export {
   makeResult,
   makeErrorResult,
   formatDocument,
+  maskTypename,
 } from './utils';
diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts
index 8c01fe95..a420bbdd 100644
--- a/packages/core/src/utils/index.ts
+++ b/packages/core/src/utils/index.ts
@@ -4,6 +4,7 @@ export * from './result';
 export * from './typenames';
 export * from './toSuspenseSource';
 export * from './stringifyVariables';
+export * from './maskTypename';
 export * from './withPromise';
 
 export const noop = () => {
diff --git a/packages/core/src/utils/maskTypename.ts b/packages/core/src/utils/maskTypename.ts
new file mode 100644
index 00000000..fdfbcae4
--- /dev/null
+++ b/packages/core/src/utils/maskTypename.ts
@@ -0,0 +1,21 @@
+export const maskTypename = (data: any): any => {
+  if (!data || typeof data !== 'object') return data;
+
+  return Object.keys(data).reduce((acc, key: string) => {
+    const value = data[key];
+    if (key === '__typename') {
+      Object.defineProperty(acc, '__typename', {
+        enumerable: false,
+        value,
+      });
+    } else if (Array.isArray(value)) {
+      acc[key] = value.map(maskTypename);
+    } else if (typeof value === 'object' && '__typename' in value) {
+      acc[key] = maskTypename(value);
+    } else {
+      acc[key] = value;
+    }
+
+    return acc;
+  }, {});
+}
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
new file mode 100644
index 00000000..cd80aea8
--- /dev/null
+++ b/packages/core/tsconfig.json
@@ -0,0 +1,12 @@
+{
+  "extends": "../../tsconfig.json",
+  "include": ["src"],
+  "compilerOptions": {
+    "baseUrl": "./",
+    "paths": {
+      "urql": ["../../node_modules/urql/src"],
+      "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/*": ["../../node_modules/@urql/*/src"]
+    }
+  }
+}
diff --git a/packages/preact-urql/src/hooks/useMutation.ts b/packages/preact-urql/src/hooks/useMutation.ts
index 0a5fffee..ddce70d5 100644
--- a/packages/preact-urql/src/hooks/useMutation.ts
+++ b/packages/preact-urql/src/hooks/useMutation.ts
@@ -43,10 +43,11 @@ export const useMutation = <T = any, V = object>(
         fetching: true,
       });
 
-      const request = createRequest(query, variables as any);
-
       return pipe(
-        client.executeMutation(request, context || {}),
+        client.executeMutation(
+          createRequest(query, variables as any),
+          context || {},
+        ),
         toPromise
       ).then(result => {
         setState({
diff --git a/packages/preact-urql/src/hooks/useQuery.ts b/packages/preact-urql/src/hooks/useQuery.ts
index 7b330f4b..a61ff6e4 100644
--- a/packages/preact-urql/src/hooks/useQuery.ts
+++ b/packages/preact-urql/src/hooks/useQuery.ts
@@ -80,14 +80,7 @@ export const useQuery = <T = any, V = object>(
       );
       unsubscribe.current = result.unsubscribe;
     },
-    [
-      args.context,
-      args.requestPolicy,
-      args.pollInterval,
-      client,
-      request,
-      setState,
-    ]
+    [setState, client, request, args.requestPolicy, args.pollInterval, args.context]
   );
 
   useImmediateEffect(() => {
diff --git a/packages/preact-urql/tsconfig.json b/packages/preact-urql/tsconfig.json
new file mode 100644
index 00000000..cd80aea8
--- /dev/null
+++ b/packages/preact-urql/tsconfig.json
@@ -0,0 +1,12 @@
+{
+  "extends": "../../tsconfig.json",
+  "include": ["src"],
+  "compilerOptions": {
+    "baseUrl": "./",
+    "paths": {
+      "urql": ["../../node_modules/urql/src"],
+      "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/*": ["../../node_modules/@urql/*/src"]
+    }
+  }
+}
diff --git a/packages/react-urql/src/hooks/useMutation.ts b/packages/react-urql/src/hooks/useMutation.ts
index 7afddffe..435cfbe5 100644
--- a/packages/react-urql/src/hooks/useMutation.ts
+++ b/packages/react-urql/src/hooks/useMutation.ts
@@ -6,7 +6,7 @@ import {
   OperationResult,
   OperationContext,
   CombinedError,
-  createRequest
+  createRequest,
 } from '@urql/core';
 
 import { useClient } from '../context';
@@ -39,10 +39,11 @@ export const useMutation = <T = any, V = object>(
     (variables?: V, context?: Partial<OperationContext>) => {
       setState({ ...initialState, fetching: true });
 
-      const request = createRequest(query, variables as any);
-
       return pipe(
-        client.executeMutation(request, context || {}),
+        client.executeMutation(
+          createRequest(query, variables as any),
+          context || {},
+        ),
         toPromise
       ).then(result => {
         setState({
diff --git a/packages/react-urql/tsconfig.json b/packages/react-urql/tsconfig.json
new file mode 100644
index 00000000..cd80aea8
--- /dev/null
+++ b/packages/react-urql/tsconfig.json
@@ -0,0 +1,12 @@
+{
+  "extends": "../../tsconfig.json",
+  "include": ["src"],
+  "compilerOptions": {
+    "baseUrl": "./",
+    "paths": {
+      "urql": ["../../node_modules/urql/src"],
+      "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/*": ["../../node_modules/@urql/*/src"]
+    }
+  }
+}
diff --git a/packages/svelte-urql/tsconfig.json b/packages/svelte-urql/tsconfig.json
new file mode 100644
index 00000000..cd80aea8
--- /dev/null
+++ b/packages/svelte-urql/tsconfig.json
@@ -0,0 +1,12 @@
+{
+  "extends": "../../tsconfig.json",
+  "include": ["src"],
+  "compilerOptions": {
+    "baseUrl": "./",
+    "paths": {
+      "urql": ["../../node_modules/urql/src"],
+      "*-urql": ["../../node_modules/*-urql/src"],
+      "@urql/*": ["../../node_modules/@urql/*/src"]
+    }
+  }
+}
diff --git a/scripts/jest/preset.js b/scripts/jest/preset.js
index fbf142d4..e20a2f7c 100644
--- a/scripts/jest/preset.js
+++ b/scripts/jest/preset.js
@@ -1,6 +1,3 @@
-const { pathsToModuleNameMapper } = require('ts-jest/utils');
-const { compilerOptions } = require('../../tsconfig.json');
-
 module.exports = {
   preset: 'ts-jest',
   setupFiles: [
@@ -9,10 +6,11 @@ module.exports = {
   transform: {
     '^.+\\.tsx?$': 'ts-jest',
   },
-  moduleNameMapper: pathsToModuleNameMapper(
-    compilerOptions.paths,
-    { prefix: '<rootDir>/../../' }
-  ),
+  moduleNameMapper: {
+    "^urql$": "<rootDir>/../../node_modules/urql/src",
+    "^(.*-urql)$": "<rootDir>/../../node_modules/$1/src",
+    "^@urql/(.*)$": "<rootDir>/../../node_modules/@urql/$1/src",
+  },
   watchPlugins: ['jest-watch-yarn-workspaces'],
   testRegex: '(src/.*(\\.|/)(test|spec))\\.tsx?$',
   moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json'],
diff --git a/scripts/rollup/build.js b/scripts/rollup/build.js
index fef4cb1f..f56bb350 100755
--- a/scripts/rollup/build.js
+++ b/scripts/rollup/build.js
@@ -1,14 +1,14 @@
 #!/usr/bin/env node
 
 const path = require('path');
+const glob = require('glob').sync;
 const execa = require('execa');
 
-const { compilerOptions: { paths } } = require('../../tsconfig.json');
 const workspaceRoot = path.resolve(__dirname, '../../');
 const rollupConfig = path.resolve(__dirname, './config.js');
 
-let packages = Object.keys(paths).map(package => {
-  return path.resolve(workspaceRoot, paths[package][0], '../');
+let packages = glob('{packages,exchanges}/*/package.json').map(pkg => {
+  return path.resolve(pkg, '../');
 });
 
 // CircleCI parallelism
diff --git a/scripts/rollup/plugins.js b/scripts/rollup/plugins.js
index 6d831636..083c6da1 100644
--- a/scripts/rollup/plugins.js
+++ b/scripts/rollup/plugins.js
@@ -36,7 +36,6 @@ export const makePlugins = ({ isProduction } = {}) => [
     : typescript({
       useTsconfigDeclarationDir: true,
       objectHashIgnoreUnknownHack: true,
-      tsconfigDefaults: require('../../tsconfig.json'),
       tsconfigOverride: {
         exclude: [
           'src/**/*.test.ts',
@@ -45,9 +44,8 @@ export const makePlugins = ({ isProduction } = {}) => [
         ],
         compilerOptions: {
           sourceMap: true,
-          baseUrl: '.',
           declaration: !isProduction,
-          declarationDir: './dist/types',
+          declarationDir: settings.types,
           target: 'es6',
         },
       },
diff --git a/scripts/rollup/settings.js b/scripts/rollup/settings.js
index ade849b1..0b8c91bc 100644
--- a/scripts/rollup/settings.js
+++ b/scripts/rollup/settings.js
@@ -1,7 +1,8 @@
 const path = require('path');
-const cwd = process.cwd();
 
+export const cwd = process.cwd();
 export const pkg = require(path.resolve(cwd, './package.json'));
+export const types = path.resolve(cwd, 'dist/types/');
 
 const normalize = name => name
   .replace(/[@\s\/\.]+/g, ' ')
diff --git a/tsconfig.json b/tsconfig.json
index 8df962b5..21254d9f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,11 +2,10 @@
   "compilerOptions": {
     "baseUrl": "./",
     "paths": {
-      "@urql/core": ["packages/core/src"],
-      "@urql/exchange-graphcache": ["exchanges/graphcache/src"],
       "urql": ["packages/react-urql/src"],
-      "preact-urql": ["packages/preact-urql/src"],
-      "svelte-urql": ["packages/svelte-urql/src"]
+      "*-urql": ["packages/*-urql/src"],
+      "@urql/exchange-*": ["exchanges/*/src"],
+      "@urql/*": ["packages/*-urql/src", "packages/*/src"]
     },
     "esModuleInterop": true,
     "forceConsistentCasingInFileNames": true,
@@ -21,10 +20,7 @@
     "noImplicitAny": false,
     "noUnusedParameters": true
   },
-  "include": [
-    "packages",
-    "exchanges"
-  ],
+  "include": ["packages", "exchanges"],
   "exclude": [
     "scripts",
     "packages/*/examples",


Test patch:
diff --git a/packages/core/src/__snapshots__/client.test.ts.snap b/packages/core/src/__snapshots__/client.test.ts.snap
index c102db58..87e27cc8 100644
--- a/packages/core/src/__snapshots__/client.test.ts.snap
+++ b/packages/core/src/__snapshots__/client.test.ts.snap
@@ -12,6 +12,7 @@ Client {
   "executeSubscription": [Function],
   "fetch": undefined,
   "fetchOptions": undefined,
+  "maskTypename": false,
   "operations$": [Function],
   "preferGetMethod": false,
   "reexecuteOperation": [Function],
diff --git a/packages/core/src/utils/maskTypename.test.ts b/packages/core/src/utils/maskTypename.test.ts
new file mode 100644
index 00000000..be980a4a
--- /dev/null
+++ b/packages/core/src/utils/maskTypename.test.ts
@@ -0,0 +1,55 @@
+import { maskTypename } from './maskTypename';
+
+it('strips typename from flat objects', () => {
+  expect(
+    maskTypename({ __typename: 'Todo', id: 1 })
+  ).toEqual({ id: 1 });
+});
+
+it('strips typename from flat objects containing dates', () => {
+  const date = new Date();
+  expect(
+    maskTypename({ __typename: 'Todo', id: 1, date })
+  ).toEqual({ id: 1, date });
+});
+
+it('strips typename from nested objects', () => {
+  expect(
+    maskTypename({
+      __typename: 'Todo',
+      id: 1,
+      author: {
+        id: 2,
+        __typename: 'Author'
+      }
+    })
+  ).toEqual({ id: 1, author: { id: 2 } });
+});
+
+it('strips typename from nested objects with arrays', () => {
+  expect(
+    maskTypename({
+      __typename: 'Todo',
+      id: 1,
+      author: {
+        id: 2,
+        __typename: 'Author',
+        books: [
+          { id: 3, __typename: 'Book', review: { id: 8, __typename: 'Review' } },
+          { id: 4, __typename: 'Book' },
+          { id: 5, __typename: 'Book' },
+        ]
+      }
+    })
+  ).toEqual({
+    id: 1,
+    author: {
+      id: 2,
+      books: [
+        { id: 3, review: { id: 8 } },
+        { id: 4 },
+        { id: 5 },
+      ]
+    }
+  });
+});
diff --git a/packages/react-urql/src/__snapshots__/context.test.ts.snap b/packages/react-urql/src/__snapshots__/context.test.ts.snap
index ee0beffb..e8e45594 100644
--- a/packages/react-urql/src/__snapshots__/context.test.ts.snap
+++ b/packages/react-urql/src/__snapshots__/context.test.ts.snap
@@ -25,6 +25,7 @@ Object {
       "executeSubscription": [Function],
       "fetch": undefined,
       "fetchOptions": undefined,
+      "maskTypename": false,
       "operations$": [Function],
       "preferGetMethod": false,
       "reexecuteOperation": [Function],
@@ -44,6 +45,7 @@ Object {
       "executeSubscription": [Function],
       "fetch": undefined,
       "fetchOptions": undefined,
+      "maskTypename": false,
       "operations$": [Function],
       "preferGetMethod": false,
       "reexecuteOperation": [Function],
@@ -82,6 +84,7 @@ Object {
       "executeSubscription": [Function],
       "fetch": undefined,
       "fetchOptions": undefined,
+      "maskTypename": false,
       "operations$": [Function],
       "preferGetMethod": false,
       "reexecuteOperation": [Function],
@@ -101,6 +104,7 @@ Object {
       "executeSubscription": [Function],
       "fetch": undefined,
       "fetchOptions": undefined,
+      "maskTypename": false,
       "operations$": [Function],
       "preferGetMethod": false,
       "reexecuteOperation": [Function],