diff --git a/.changeset/modern-queens-run.md b/.changeset/modern-queens-run.md new file mode 100644 index 0000000000..396de5991a --- /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/packages/core/src/__snapshots__/client.test.ts.snap b/packages/core/src/__snapshots__/client.test.ts.snap index c102db58bd..87e27cc846 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/client.ts b/packages/core/src/client.ts index 0ae87d7e8c..a2829c3b08 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 { 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 7dad29c178..0c631ea4db 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 8c01fe9570..a420bbdd4e 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.test.ts b/packages/core/src/utils/maskTypename.test.ts new file mode 100644 index 0000000000..be980a4ad7 --- /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/core/src/utils/maskTypename.ts b/packages/core/src/utils/maskTypename.ts new file mode 100644 index 0000000000..fdfbcae4b1 --- /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/preact-urql/src/hooks/useMutation.ts b/packages/preact-urql/src/hooks/useMutation.ts index 0a5fffee54..ddce70d5e3 100644 --- a/packages/preact-urql/src/hooks/useMutation.ts +++ b/packages/preact-urql/src/hooks/useMutation.ts @@ -43,10 +43,11 @@ export const useMutation = ( 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 7b330f4bef..a61ff6e42f 100644 --- a/packages/preact-urql/src/hooks/useQuery.ts +++ b/packages/preact-urql/src/hooks/useQuery.ts @@ -80,14 +80,7 @@ export const useQuery = ( ); 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/react-urql/src/__snapshots__/context.test.ts.snap b/packages/react-urql/src/__snapshots__/context.test.ts.snap index ee0beffbfb..e8e4559474 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], diff --git a/packages/react-urql/src/hooks/useMutation.ts b/packages/react-urql/src/hooks/useMutation.ts index 7afddffe2a..435cfbe545 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 = ( (variables?: V, context?: Partial) => { 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({