diff --git a/.changeset/two-bananas-grow.md b/.changeset/two-bananas-grow.md new file mode 100644 index 0000000000..3cf5b51609 --- /dev/null +++ b/.changeset/two-bananas-grow.md @@ -0,0 +1,5 @@ +--- +'@urql/core': minor +--- + +Add support for sending persisted documents. Any `DocumentNode` with no/empty definitions and a `documentId` property is considered a persisted document. When this is detected a `documentId` parameter rather than a `query` string is sent to the GraphQL API, similar to Automatic Persisted Queries (APQs). However, APQs are only supported via `@urql/exchange-persisted`, while support for `documentId` is now built-in. diff --git a/packages/core/src/internal/fetchOptions.test.ts b/packages/core/src/internal/fetchOptions.test.ts index e0142bf612..0787101f07 100644 --- a/packages/core/src/internal/fetchOptions.test.ts +++ b/packages/core/src/internal/fetchOptions.test.ts @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { expect, describe, it } from 'vitest'; +import { Kind } from '@0no-co/graphql.web'; import { makeOperation } from '../utils/operation'; import { queryOperation, mutationOperation } from '../test-utils'; import { makeFetchBody, makeFetchURL, makeFetchOptions } from './fetchOptions'; @@ -10,6 +11,7 @@ describe('makeFetchBody', () => { const body = makeFetchBody(queryOperation); expect(body).toMatchInlineSnapshot(` { + "documentId": undefined, "extensions": undefined, "operationName": "getUser", "query": "query getUser($name: String) { @@ -42,6 +44,22 @@ describe('makeFetchBody', () => { apqOperation.extensions.persistedQuery!.miss = true; expect(makeFetchBody(apqOperation).query).not.toBe(undefined); }); + + it('omits the query property when query is a persisted document', () => { + // A persisted documents is one that carries a `documentId` property and + // has no definitions + const persistedOperation = makeOperation(queryOperation.kind, { + ...queryOperation, + query: { + kind: Kind.DOCUMENT, + definitions: [], + documentId: 'TestDocumentId', + }, + }); + + expect(makeFetchBody(persistedOperation).query).toBe(undefined); + expect(makeFetchBody(persistedOperation).documentId).toBe('TestDocumentId'); + }); }); describe('makeFetchURL', () => { diff --git a/packages/core/src/internal/fetchOptions.ts b/packages/core/src/internal/fetchOptions.ts index ef9289464e..bede655990 100644 --- a/packages/core/src/internal/fetchOptions.ts +++ b/packages/core/src/internal/fetchOptions.ts @@ -10,6 +10,7 @@ import type { AnyVariables, GraphQLRequest, Operation } from '../types'; /** Abstract definition of the JSON data sent during GraphQL HTTP POST requests. */ export interface FetchBody { query?: string; + documentId?: string; operationName: string | undefined; variables: undefined | Record; extensions: undefined | Record; @@ -24,16 +25,31 @@ export function makeFetchBody< Data = any, Variables extends AnyVariables = AnyVariables, >(request: Omit, 'key'>): FetchBody { - const isAPQ = - request.extensions && - request.extensions.persistedQuery && - !request.extensions.persistedQuery.miss; - return { - query: isAPQ ? undefined : stringifyDocument(request.query), + const body: FetchBody = { + query: undefined, + documentId: undefined, operationName: getOperationName(request.query), variables: request.variables || undefined, extensions: request.extensions, }; + + if ( + 'documentId' in request.query && + request.query.documentId && + // NOTE: We have to check that the document will definitely be sent + // as a persisted document to avoid breaking changes + (!request.query.definitions || !request.query.definitions.length) + ) { + body.documentId = request.query.documentId; + } else if ( + !request.extensions || + !request.extensions.persistedQuery || + !!request.extensions.persistedQuery.miss + ) { + body.query = stringifyDocument(request.query); + } + + return body; } /** Creates a URL that will be called for a GraphQL HTTP request. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 33ab727fc6..185415c162 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -9,6 +9,11 @@ import type { Subscription, Source } from 'wonka'; import type { Client } from './client'; import type { CombinedError } from './utils/error'; +/** A GraphQL persisted document will contain `documentId` that replaces its definitions */ +export interface PersistedDocument extends DocumentNode { + documentId?: string; +} + /** A GraphQL `DocumentNode` with attached generics for its result data and variables. * * @remarks @@ -340,7 +345,7 @@ export interface GraphQLRequest< * In `urql`, we expect a document to only contain a single operation that is executed rather than * multiple ones by convention. */ - query: DocumentNode | TypedDocumentNode; + query: DocumentNode | PersistedDocument | TypedDocumentNode; /** Variables used to execute the `query` document. * * @remarks