From 917ee015d7aaeed8c95f275aa504c7cbb0f0b3d4 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Fri, 21 Aug 2026 16:58:23 +0000 Subject: [PATCH] feat(core): support HTTP QUERY requests --- .changeset/calm-queries-travel.md | 5 ++ docs/api/core.md | 20 ++--- packages/core/src/client.test.ts | 12 +++ packages/core/src/client.ts | 18 +++++ .../core/src/internal/fetchOptions.test.ts | 78 ++++++++++++++++++- packages/core/src/internal/fetchOptions.ts | 16 +++- packages/core/src/types.ts | 15 ++++ 7 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 .changeset/calm-queries-travel.md diff --git a/.changeset/calm-queries-travel.md b/.changeset/calm-queries-travel.md new file mode 100644 index 0000000000..2dc7604094 --- /dev/null +++ b/.changeset/calm-queries-travel.md @@ -0,0 +1,5 @@ +--- +'@urql/core': minor +--- + +Add an opt-in `preferQueryMethod` option that sends JSON query operations as body-bearing HTTP QUERY requests. diff --git a/docs/api/core.md b/docs/api/core.md index 1d9f4b6901..e940f12542 100644 --- a/docs/api/core.md +++ b/docs/api/core.md @@ -25,15 +25,16 @@ It accepts several options on creation. `@urql/core` also exposes `createClient()` that is just a convenient alternative to calling `new Client()`. -| Input | Type | Description | -| ----------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `exchanges` | `Exchange[]` | An array of `Exchange`s that the client should use | -| `url` | `string` | The GraphQL API URL as used by `fetchExchange` | -| `fetchOptions` | `RequestInit \| () => RequestInit` | Additional `fetchOptions` that `fetch` in `fetchExchange` should use to make a request | -| `fetch` | `typeof fetch` | An alternative implementation of `fetch` that will be used by the `fetchExchange` instead of `window.fetch` | -| `suspense` | `?boolean` | Activates the experimental React suspense mode, which can be used during server-side rendering to prefetch data | -| `requestPolicy` | `?RequestPolicy` | Changes the default request policy that will be used. By default, this will be `cache-first`. | -| `preferGetMethod` | `?boolean \| 'force' \| 'within-url-limit'` | This is picked up by the `fetchExchange` and will force all queries (not mutations) to be sent using the HTTP GET method instead of POST if the length of the resulting URL doesn't exceed 2048 characters. When `'force'` is passed a GET request is always sent regardless of how long the resulting URL is. | +| Input | Type | Description | +| ------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `exchanges` | `Exchange[]` | An array of `Exchange`s that the client should use | +| `url` | `string` | The GraphQL API URL as used by `fetchExchange` | +| `fetchOptions` | `RequestInit \| () => RequestInit` | Additional `fetchOptions` that `fetch` in `fetchExchange` should use to make a request | +| `fetch` | `typeof fetch` | An alternative implementation of `fetch` that will be used by the `fetchExchange` instead of `window.fetch` | +| `suspense` | `?boolean` | Activates the experimental React suspense mode, which can be used during server-side rendering to prefetch data | +| `requestPolicy` | `?RequestPolicy` | Changes the default request policy that will be used. By default, this will be `cache-first`. | +| `preferGetMethod` | `?boolean \| 'force' \| 'within-url-limit'` | This is picked up by the `fetchExchange` and will force all queries (not mutations) to be sent using the HTTP GET method instead of POST if the length of the resulting URL doesn't exceed 2048 characters. When `'force'` is passed a GET request is always sent regardless of how long the resulting URL is. | +| `preferQueryMethod` | `?boolean` | Instructs the `fetchExchange` to send JSON query operations using body-bearing HTTP QUERY requests. This takes precedence over `preferGetMethod` and requires server and HTTP infrastructure support for QUERY. Mutations and subscriptions are unaffected; multipart requests continue to use POST. | ### client.executeQuery @@ -212,6 +213,7 @@ properties you'll likely see some options that exist on the `Client` as well. | `meta` | `?OperationDebugMeta` | Metadata that is only available in development for devtools. | | `suspense` | `?boolean` | Whether suspense is enabled. | | `preferGetMethod` | `?boolean \| 'force' \| 'within-url-limit'` | Instructs the `fetchExchange` to use HTTP GET for queries. | +| `preferQueryMethod` | `?boolean` | Instructs the `fetchExchange` to use body-bearing HTTP QUERY requests for JSON query operations. | | `additionalTypenames` | `?string[]` | Allows you to tell the operation that it depends on certain typenames (used in document-cache.) | It also accepts additional, untyped parameters that can be used to send more diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts index bad8c24734..b8d684f056 100755 --- a/packages/core/src/client.test.ts +++ b/packages/core/src/client.test.ts @@ -153,6 +153,18 @@ describe('promisified methods', () => { expect(queryResult).toHaveProperty('then'); }); + it('passes preferQueryMethod to query operations', () => { + client = createClient({ + url, + exchanges: [exchangeMock] as any[], + preferQueryMethod: true, + }); + + client.query(query.query, query.variables).subscribe(() => {}); + + expect(receivedOps[0].context.preferQueryMethod).toBe(true); + }); + it('mutation', () => { const mut = gql` mutation { diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 3a7c0f2fde..afd79f58de 100755 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -167,6 +167,21 @@ export interface ClientOptions { * requests for queries. */ preferGetMethod?: boolean | 'force' | 'within-url-limit'; + /** Instructs fetch exchanges to use an HTTP QUERY request. + * + * @remarks + * This changes the {@link OperationContext.preferQueryMethod} option, which tells fetch exchanges + * to use body-bearing QUERY requests for query operations instead of GET or POST requests. + * Mutations and subscriptions are unaffected. Multipart requests continue to use POST. + * + * This option takes precedence over {@link ClientOptions.preferGetMethod}. + * The GraphQL server and HTTP infrastructure must support the QUERY method. + * Cross-origin browser requests require the server's CORS policy to allow QUERY. + * Browser HTTP caches may not cache QUERY responses yet. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc10008.html} for the HTTP QUERY method. + */ + preferQueryMethod?: boolean; } /** The `Client` is the central hub for your GraphQL operations and holds `urql`'s state. @@ -551,6 +566,9 @@ export const Client: new (opts: ClientOptions) => Client = function Client( fetch: opts.fetch, preferGetMethod: opts.preferGetMethod != null ? opts.preferGetMethod : 'within-url-limit', + ...(opts.preferQueryMethod != null + ? { preferQueryMethod: opts.preferQueryMethod } + : {}), requestPolicy: opts.requestPolicy || 'cache-first', }; diff --git a/packages/core/src/internal/fetchOptions.test.ts b/packages/core/src/internal/fetchOptions.test.ts index dd13d43c46..6f5a036a61 100644 --- a/packages/core/src/internal/fetchOptions.test.ts +++ b/packages/core/src/internal/fetchOptions.test.ts @@ -3,7 +3,11 @@ 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 { + queryOperation, + mutationOperation, + subscriptionOperation, +} from '../test-utils'; import { makeFetchBody, makeFetchURL, makeFetchOptions } from './fetchOptions'; describe('makeFetchBody', () => { @@ -90,6 +94,17 @@ describe('makeFetchURL', () => { ); }); + it('keeps the request body out of the URL when QUERY is preferred', () => { + const operation = makeOperation(queryOperation.kind, queryOperation, { + ...queryOperation.context, + preferGetMethod: 'force', + preferQueryMethod: true, + }); + + const body = makeFetchBody(operation); + expect(makeFetchURL(operation, body)).toBe('http://localhost:3000/graphql'); + }); + it('returns a query parameter URL when GET is preferred and only a path is provided', () => { const operation = makeOperation(queryOperation.kind, queryOperation, { url: '/graphql', @@ -209,6 +224,67 @@ describe('makeFetchOptions', () => { `); }); + it('creates a QUERY request with a JSON body when preferred', () => { + const operation = makeOperation(queryOperation.kind, queryOperation, { + ...queryOperation.context, + preferGetMethod: 'force', + preferQueryMethod: true, + }); + + const body = makeFetchBody(operation); + const options = makeFetchOptions(operation, body); + expect(options.body).toBe(makeFetchOptions(queryOperation, body).body); + expect(options).toMatchObject({ + body: expect.any(String), + headers: { + accept: + 'application/graphql-response+json, application/graphql+json, application/json, text/event-stream, multipart/mixed', + 'content-type': 'application/json', + }, + method: 'QUERY', + }); + }); + + it('keeps mutations on POST when QUERY is preferred', () => { + const operation = makeOperation(mutationOperation.kind, mutationOperation, { + ...mutationOperation.context, + preferQueryMethod: true, + }); + + const body = makeFetchBody(operation); + expect(makeFetchOptions(operation, body).method).toBe('POST'); + }); + + it('keeps subscriptions on POST when QUERY is preferred', () => { + const operation = makeOperation( + subscriptionOperation.kind, + subscriptionOperation, + { + ...subscriptionOperation.context, + preferQueryMethod: true, + } + ); + + const body = makeFetchBody(operation); + expect(makeFetchOptions(operation, body).method).toBe('POST'); + }); + + it('keeps multipart queries on POST when QUERY is preferred', () => { + const operation = makeOperation(queryOperation.kind, queryOperation, { + ...queryOperation.context, + preferQueryMethod: true, + }); + operation.variables = { + ...operation.variables, + file: new Blob(), + }; + + const body = makeFetchBody(operation); + const options = makeFetchOptions(operation, body); + expect(options.method).toBe('POST'); + expect(options.body).toBeInstanceOf(FormData); + }); + it('creates a POST multipart request when a file is detected', () => { const operation = makeOperation(mutationOperation.kind, mutationOperation); operation.variables = { diff --git a/packages/core/src/internal/fetchOptions.ts b/packages/core/src/internal/fetchOptions.ts index 025399f47f..25e8857697 100644 --- a/packages/core/src/internal/fetchOptions.ts +++ b/packages/core/src/internal/fetchOptions.ts @@ -7,7 +7,7 @@ import { import type { AnyVariables, GraphQLRequest, Operation } from '../types'; -/** Abstract definition of the JSON data sent during GraphQL HTTP POST requests. */ +/** Abstract definition of the JSON data sent during GraphQL HTTP requests. */ export interface FetchBody { query?: string; documentId?: string; @@ -68,7 +68,9 @@ export const makeFetchURL = ( body?: FetchBody ): string => { const useGETMethod = - operation.kind === 'query' && operation.context.preferGetMethod; + operation.kind === 'query' && + !operation.context.preferQueryMethod && + operation.context.preferGetMethod; if (!useGETMethod || !body) return operation.context.url; const urlParts = splitOutSearchParams(operation.context.url); @@ -105,7 +107,9 @@ const serializeBody = ( body?: FetchBody ): FormData | string | undefined => { const omitBody = - operation.kind === 'query' && !!operation.context.preferGetMethod; + operation.kind === 'query' && + !operation.context.preferQueryMethod && + !!operation.context.preferGetMethod; if (body && !omitBody) { const json = stringifyVariables(body); const files = extractFiles(body.variables); @@ -183,11 +187,15 @@ export const makeFetchOptions = ( } const serializedBody = serializeBody(operation, body); + const useQUERYMethod = + operation.kind === 'query' && + operation.context.preferQueryMethod && + typeof serializedBody === 'string'; if (typeof serializedBody === 'string' && !headers['content-type']) headers['content-type'] = 'application/json'; return { ...extraOptions, - method: serializedBody ? 'POST' : 'GET', + method: serializedBody ? (useQUERYMethod ? 'QUERY' : 'POST') : 'GET', body: serializedBody, headers, }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b3a2ff616d..40fd20caca 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -586,6 +586,21 @@ export interface OperationContext { * this option to `'force'`. */ preferGetMethod?: boolean | 'force' | 'within-url-limit'; + /** Instructs fetch exchanges to use an HTTP QUERY request. + * + * @remarks + * When set to `true`, built-in fetch exchanges send query operations as + * body-bearing HTTP QUERY requests. Mutations and subscriptions are unaffected. + * Multipart requests continue to use POST. + * + * This option takes precedence over {@link OperationContext.preferGetMethod}. + * The GraphQL server and HTTP infrastructure must support the QUERY method. + * Cross-origin browser requests require the server's CORS policy to allow QUERY. + * Browser HTTP caches may not cache QUERY responses yet. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc10008.html} for the HTTP QUERY method. + */ + preferQueryMethod?: boolean; /** A configuration flag indicating whether this operation may trigger "Suspense". * * @remarks