Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.

Provide possibility to mask the __typename in client results (#533)

* implement a stripTypename helper in core

* add option to strip __typename in react-urql

* add option to strip __typename in preact-urql

* strip __typename from mutation variables

* add stripTypename to useRequest

* bail out of stripTypename when we pass in a falsy value

* run changeset

* refactor to use .reduce

* remove useRequest variables conversion and test new defineProperty

* add another fallback for non-object data-points

* add stripTypename to mutations and subscriptions

* support dates

* update changeset

* Update .changeset/modern-queens-run.md

Co-Authored-By: Phil Plückthun <phil@kitten.sh>

* Update .changeset/modern-queens-run.md

Co-Authored-By: Phil Plückthun <phil@kitten.sh>

* refactor masking typenames

* update changeset

* properly export maskTypename and update snapshots

* update changeset

* Update .changeset/modern-queens-run.md

Co-Authored-By: Phil Plückthun <phil@kitten.sh>

Co-authored-by: Phil Plückthun <phil@kitten.sh>

Changed files
+117 -17
.changeset
packages
core
preact-urql
react-urql
src
__snapshots__
hooks
+7
.changeset/modern-queens-run.md
···
+
---
+
'@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.
+1
packages/core/src/__snapshots__/client.test.ts.snap
···
"executeSubscription": [Function],
"fetch": undefined,
"fetchOptions": undefined,
+
"maskTypename": false,
"operations$": [Function],
"preferGetMethod": false,
"reexecuteOperation": [Function],
+17 -2
packages/core/src/client.ts
···
switchMap,
publish,
subscribe,
+
map,
} from 'wonka';
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}. */
···
requestPolicy?: RequestPolicy;
/** Use HTTP GET for queries. */
preferGetMethod?: boolean;
+
/** Mask __typename from results. */
+
maskTypename?: boolean;
}
interface ActiveOperations {
···
suspense: boolean;
preferGetMethod: boolean;
requestPolicy: RequestPolicy;
+
maskTypename: boolean;
// These are internals to be used to keep track of operations
dispatchOperation: (operation: Operation) => void;
···
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
···
/** 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
+1
packages/core/src/index.ts
···
makeResult,
makeErrorResult,
formatDocument,
+
maskTypename,
} from './utils';
+1
packages/core/src/utils/index.ts
···
export * from './typenames';
export * from './toSuspenseSource';
export * from './stringifyVariables';
+
export * from './maskTypename';
export * from './withPromise';
export const noop = () => {
+55
packages/core/src/utils/maskTypename.test.ts
···
+
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 },
+
]
+
}
+
});
+
});
+21
packages/core/src/utils/maskTypename.ts
···
+
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;
+
}, {});
+
}
+4 -3
packages/preact-urql/src/hooks/useMutation.ts
···
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({
+1 -8
packages/preact-urql/src/hooks/useQuery.ts
···
);
unsubscribe.current = result.unsubscribe;
},
-
[
-
args.context,
-
args.requestPolicy,
-
args.pollInterval,
-
client,
-
request,
-
setState,
-
]
+
[setState, client, request, args.requestPolicy, args.pollInterval, args.context]
);
useImmediateEffect(() => {
+4
packages/react-urql/src/__snapshots__/context.test.ts.snap
···
"executeSubscription": [Function],
"fetch": undefined,
"fetchOptions": undefined,
+
"maskTypename": false,
"operations$": [Function],
"preferGetMethod": false,
"reexecuteOperation": [Function],
···
"executeSubscription": [Function],
"fetch": undefined,
"fetchOptions": undefined,
+
"maskTypename": false,
"operations$": [Function],
"preferGetMethod": false,
"reexecuteOperation": [Function],
···
"executeSubscription": [Function],
"fetch": undefined,
"fetchOptions": undefined,
+
"maskTypename": false,
"operations$": [Function],
"preferGetMethod": false,
"reexecuteOperation": [Function],
···
"executeSubscription": [Function],
"fetch": undefined,
"fetchOptions": undefined,
+
"maskTypename": false,
"operations$": [Function],
"preferGetMethod": false,
"reexecuteOperation": [Function],
+5 -4
packages/react-urql/src/hooks/useMutation.ts
···
OperationResult,
OperationContext,
CombinedError,
-
createRequest
+
createRequest,
} from '@urql/core';
import { useClient } from '../context';
···
(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({