diff --git a/.changeset/rude-mayflies-scream.md b/.changeset/rude-mayflies-scream.md new file mode 100644 index 00000000000..d3896e963a9 --- /dev/null +++ b/.changeset/rude-mayflies-scream.md @@ -0,0 +1,5 @@ +--- +'@apollo/client': minor +--- + +Add the ability to allow `@client` fields to be sent to the link chain. diff --git a/config/bundlesize.ts b/config/bundlesize.ts index 7c005a5ead0..33b1cd94112 100644 --- a/config/bundlesize.ts +++ b/config/bundlesize.ts @@ -3,7 +3,7 @@ import { join } from "path"; import { gzipSync } from "zlib"; import bytes from "bytes"; -const gzipBundleByteLengthLimit = bytes("33.01KB"); +const gzipBundleByteLengthLimit = bytes("33.17KB"); const minFile = join("dist", "apollo-client.min.cjs"); const minPath = join(__dirname, "..", minFile); const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength; diff --git a/src/__tests__/client.ts b/src/__tests__/client.ts index 322c14070ec..1abff321499 100644 --- a/src/__tests__/client.ts +++ b/src/__tests__/client.ts @@ -1,5 +1,5 @@ import { cloneDeep, assign } from 'lodash'; -import { GraphQLError, ExecutionResult, DocumentNode } from 'graphql'; +import { GraphQLError, ExecutionResult, DocumentNode, print } from 'graphql'; import gql from 'graphql-tag'; import { @@ -8,6 +8,7 @@ import { WatchQueryFetchPolicy, QueryOptions, ObservableQuery, + Operation, TypedDocumentNode, } from '../core'; @@ -940,6 +941,98 @@ describe('client', () => { .then(resolve, reject); }); + it('removes @client fields from the query before it reaches the link', async () => { + const result: { current: Operation | undefined } = { + current: undefined + } + + const query = gql` + query { + author { + firstName + lastName + isInCollection @client + } + } + `; + + const transformedQuery = gql` + query { + author { + firstName + lastName + } + } + `; + + const link = new ApolloLink((operation) => { + result.current = operation; + + return Observable.of({ + data: { + author: { + firstName: 'John', + lastName: 'Smith', + __typename: 'Author', + } + } + }); + }); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }), + }); + + await client.query({ query }); + + expect(print(result.current!.query)).toEqual(print(transformedQuery)); + }); + + it('sends @client fields to the link when defaultOptions.transformQuery.removeClientFields is `false`', async () => { + const result: { current: Operation | undefined } = { + current: undefined + }; + + const query = gql` + query { + author { + firstName + lastName + isInCollection @client + } + } + `; + + const link = new ApolloLink((operation) => { + result.current = operation + + return Observable.of({ + data: { + author: { + firstName: 'John', + lastName: 'Smith', + __typename: 'Author', + } + } + }); + }); + + const client = new ApolloClient({ + link, + cache: new InMemoryCache({ addTypename: false }), + defaultOptions: { + transformQuery: { + removeClientFields: false, + } + } + }); + + await client.query({ query }); + + expect(print(result.current!.query)).toEqual(print(query)); + }); + itAsync('should handle named fragments on mutations', (resolve, reject) => { const mutation = gql` mutation { diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index b26da37e579..96d11c56baf 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -20,6 +20,7 @@ import { RefetchQueriesResult, InternalRefetchQueriesResult, RefetchQueriesInclude, + TransformQueryOptions, } from './types'; import { @@ -39,6 +40,7 @@ export interface DefaultOptions { watchQuery?: Partial>; query?: Partial>; mutate?: Partial>; + transformQuery?: Partial; } let hasSuggestedDevtools = false; diff --git a/src/core/LocalState.ts b/src/core/LocalState.ts index e6e9518016b..d4c8fe4ee74 100644 --- a/src/core/LocalState.ts +++ b/src/core/LocalState.ts @@ -171,9 +171,16 @@ export class LocalState { return null; } - // Server queries are stripped of all @client based selection sets. - public serverQuery(document: DocumentNode) { - return removeClientSetsFromDocument(document); + // Server queries by default are stripped of all @client based selection sets. + public serverQuery( + document: DocumentNode, + options: { removeClientFields?: boolean } = Object.create(null) + ) { + const { removeClientFields = true } = options; + + return removeClientFields + ? removeClientSetsFromDocument(document) + : document; } public prepareContext(context?: Record) { diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index ecebf55c554..36b905ec43d 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -607,12 +607,17 @@ export class QueryManager { public transform(document: DocumentNode) { const { transformCache } = this; + const { + removeClientFields = true + } = this.defaultOptions.transformQuery || Object.create(null); if (!transformCache.has(document)) { const transformed = this.cache.transformDocument(document); const noConnection = removeConnectionDirectiveFromDocument(transformed); const clientQuery = this.localState.clientQuery(transformed); - const serverQuery = noConnection && this.localState.serverQuery(noConnection); + const serverQuery = + noConnection && + this.localState.serverQuery(noConnection, { removeClientFields }); const cacheEntry: TransformCacheEntry = { document: transformed, diff --git a/src/core/__tests__/QueryManager/links.ts b/src/core/__tests__/QueryManager/links.ts index 140af995013..ccee150703b 100644 --- a/src/core/__tests__/QueryManager/links.ts +++ b/src/core/__tests__/QueryManager/links.ts @@ -1,5 +1,6 @@ // externals import gql from 'graphql-tag'; +import { print } from 'graphql' import { Observable, ObservableSubscription } from '../../../utilities/observables/Observable'; import { ApolloLink } from '../../../link/core'; @@ -360,4 +361,104 @@ describe('Link interactions', () => { }); }); }); + + it('removes @client fields from the query before it reaches the link', async () => { + const result: { current: Operation | undefined } = { + current: undefined + }; + + const query = gql` + query { + books { + id + title + isRead @client + } + } + `; + + const expectedQuery = gql` + query { + books { + id + title + } + } + `; + + const link = new ApolloLink((operation) => { + result.current = operation; + + return Observable.of({ + data: { + books: [ + { id: 1, title: 'Woo', __typename: 'Book' }, + { id: 2, title: 'Foo', __typename: 'Book' }, + ], + } + }); + }); + + const queryManager = new QueryManager({ + link, + cache: new InMemoryCache({ addTypename: false }), + }); + + await queryManager.query({ query }); + + expect(print(result.current!.query)).toEqual(print(expectedQuery)) + }); + + it('sends @client fields to the link when defaultOptions.transformQuery.removeClientFields is false', async () => { + const result: { current: Operation | undefined } = { + current: undefined + }; + + const query = gql` + query { + books { + id + title + isRead @client + } + } + `; + + const expectedQuery = gql` + query { + books { + id + title + isRead @client + } + } + `; + + const link = new ApolloLink((operation) => { + result.current = operation; + + return Observable.of({ + data: { + books: [ + { id: 1, title: 'Woo', __typename: 'Book' }, + { id: 2, title: 'Foo', __typename: 'Book' }, + ], + } + }); + }); + + const queryManager = new QueryManager({ + link, + cache: new InMemoryCache({ addTypename: false }), + defaultOptions: { + transformQuery: { + removeClientFields: false + } + } + }); + + await queryManager.query({ query }); + + expect(print(result.current!.query)).toEqual(print(expectedQuery)) + }); }); diff --git a/src/core/types.ts b/src/core/types.ts index 9d34bd4050b..aa9fb2d9fbb 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -140,7 +140,7 @@ export type ApolloQueryResult = { */ errors?: ReadonlyArray; /** - * The single Error object that is passed to onError and useQuery hooks, and is often thrown during manual `client.query` calls. + * The single Error object that is passed to onError and useQuery hooks, and is often thrown during manual `client.query` calls. * This will contain both a NetworkError field and any GraphQLErrors. * See https://www.apollographql.com/docs/react/data/error-handling/ for more information. */ @@ -194,3 +194,11 @@ export interface Resolvers { [ field: string ]: Resolver; }; } + +export interface TransformQueryOptions { + /** + * Determines whether fields using the `@client` directive should be removed + * from the query before it is sent through the link chain. Defaults to `true`. + */ + removeClientFields?: boolean +} diff --git a/src/link/batch-http/__tests__/batchHttpLink.ts b/src/link/batch-http/__tests__/batchHttpLink.ts index e9b22ef431c..bbaf12bedd5 100644 --- a/src/link/batch-http/__tests__/batchHttpLink.ts +++ b/src/link/batch-http/__tests__/batchHttpLink.ts @@ -582,7 +582,7 @@ describe('SharedHttpTest', () => { const variables = { params: 'stub' }; const link = createHttpLink({ uri: '/data', - headers: { + headers: { authorization: '1234', AUTHORIZATION: '1234', 'CONTENT-TYPE': 'application/json', @@ -898,4 +898,76 @@ describe('SharedHttpTest', () => { () => {}, ); }); + + it('removes @client fields from the query before sending it to the server', async () => { + fetchMock.mock('https://example.com/graphql', { + status: 200, + body: JSON.stringify({ + data: { + author: { __typename: 'Author', name: 'Test User' } + } + }), + headers: { 'content-type': 'application/json' } + }); + + const query = gql` + query { + author { + name + isInCollection @client + } + } + `; + + const serverQuery = gql` + query { + author { + name + } + } + `; + + const link = createHttpLink({ uri: 'https://example.com/graphql' }); + + await new Promise((resolve, reject) => { + execute(link, { query }).subscribe({ + next: resolve, + error: reject + }); + }); + + const [, options] = fetchMock.lastCall()!; + const { body } = options! + + expect(JSON.parse(body!.toString())).toEqual([ + { + query: print(serverQuery), + variables: {} + } + ]); + }); + + it('responds with error when trying to send a client-only query', async () => { + const errorHandler = jest.fn() + const query = gql` + query { + author @client { + name + } + } + `; + + const link = createHttpLink({ uri: 'https://example.com/graphql' }); + + await new Promise((resolve, reject) => { + execute(link, { query }).subscribe({ + next: reject, + error: errorHandler.mockImplementation(resolve) + }); + }); + + expect(errorHandler).toHaveBeenCalledWith( + new Error('BatchHttpLink: Trying to send a client-only query to the server. To send to the server, ensure a non-client field is added to the query or enable the `transformOptions.removeClientFields` option.') + ); + }); }); diff --git a/src/link/batch-http/batchHttpLink.ts b/src/link/batch-http/batchHttpLink.ts index 73c065f33a9..75dec9d3586 100644 --- a/src/link/batch-http/batchHttpLink.ts +++ b/src/link/batch-http/batchHttpLink.ts @@ -1,5 +1,9 @@ import { ApolloLink, Operation, FetchResult } from '../core'; -import { Observable } from '../../utilities'; +import { + Observable, + hasDirectives, + removeClientSetsFromDocument +} from '../../utilities'; import { fromError } from '../utils'; import { serializeFetchParameter, @@ -95,10 +99,28 @@ export class BatchHttpLink extends ApolloLink { headers: { ...clientAwarenessHeaders, ...context.headers }, }; + const queries = operations.map(({ query }) => { + if (hasDirectives(['client'], query)) { + return removeClientSetsFromDocument(query); + } + + return query; + }); + + // If we have a query that returned `null` after removing client-only + // fields, it indicates a query that is using all client-only fields. + if (queries.some(query => !query)) { + return fromError( + new Error( + 'BatchHttpLink: Trying to send a client-only query to the server. To send to the server, ensure a non-client field is added to the query or enable the `transformOptions.removeClientFields` option.' + ) + ); + } + //uses fallback, link, and then context to build options - const optsAndBody = operations.map(operation => + const optsAndBody = operations.map((operation, index) => selectHttpOptionsAndBodyInternal( - operation, + { ...operation, query: queries[index]! }, print, fallbackHttpConfig, linkConfig, diff --git a/src/link/http/__tests__/HttpLink.ts b/src/link/http/__tests__/HttpLink.ts index 07f8e7d404e..8b24a8f54bd 100644 --- a/src/link/http/__tests__/HttpLink.ts +++ b/src/link/http/__tests__/HttpLink.ts @@ -1014,6 +1014,76 @@ describe('HttpLink', () => { () => {}, ); }); + + it('removes @client fields from the query before sending it to the server', async () => { + fetchMock.mock('https://example.com/graphql', { + status: 200, + body: JSON.stringify({ + data: { + author: { __typename: 'Author', name: 'Test User' } + } + }), + headers: { 'content-type': 'application/json' } + }); + + const query = gql` + query { + author { + name + isInCollection @client + } + } + `; + + const serverQuery = gql` + query { + author { + name + } + } + `; + + const link = createHttpLink({ uri: 'https://example.com/graphql' }); + + await new Promise((resolve, reject) => { + execute(link, { query }).subscribe({ + next: resolve, + error: reject + }); + }); + + const [, options] = fetchMock.lastCall()!; + const { body } = options! + + expect(JSON.parse(body!.toString())).toEqual({ + query: print(serverQuery), + variables: {} + }); + }); + + it('responds with error when trying to send a client-only query', async () => { + const errorHandler = jest.fn() + const query = gql` + query { + author @client { + name + } + } + `; + + const link = createHttpLink({ uri: 'https://example.com/graphql' }); + + await new Promise((resolve, reject) => { + execute(link, { query }).subscribe({ + next: reject, + error: errorHandler.mockImplementation(resolve) + }); + }); + + expect(errorHandler).toHaveBeenCalledWith( + new Error('HttpLink: Trying to send a client-only query to the server. To send to the server, ensure a non-client field is added to the query or set the `transformOptions.removeClientFields` option to `true`.') + ); + }) }); describe('Dev warnings', () => { diff --git a/src/link/http/createHttpLink.ts b/src/link/http/createHttpLink.ts index b97d9faf6f8..8bf2b41e107 100644 --- a/src/link/http/createHttpLink.ts +++ b/src/link/http/createHttpLink.ts @@ -21,7 +21,7 @@ import { import { createSignalIfSupported } from './createSignalIfSupported'; import { rewriteURIForGET } from './rewriteURIForGET'; import { fromError } from '../utils'; -import { maybe } from '../../utilities'; +import { maybe, removeClientSetsFromDocument } from '../../utilities'; const backupFetch = maybe(() => fetch); @@ -86,6 +86,20 @@ export const createHttpLink = (linkOptions: HttpOptions = {}) => { headers: contextHeaders, }; + if (hasDirectives(['client'], operation.query)) { + const transformedQuery = removeClientSetsFromDocument(operation.query); + + if (!transformedQuery) { + return fromError( + new Error( + 'HttpLink: Trying to send a client-only query to the server. To send to the server, ensure a non-client field is added to the query or set the `transformOptions.removeClientFields` option to `true`.' + ) + ); + } + + operation.query = transformedQuery; + } + //uses fallback, link, and then context to build options const { options, body } = selectHttpOptionsAndBodyInternal( operation,