Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-queries-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@urql/core': minor
---

Add an opt-in `preferQueryMethod` option that sends JSON query operations as body-bearing HTTP QUERY requests.
20 changes: 11 additions & 9 deletions docs/api/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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',
};

Expand Down
78 changes: 77 additions & 1 deletion packages/core/src/internal/fetchOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 = {
Expand Down
16 changes: 12 additions & 4 deletions packages/core/src/internal/fetchOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
};
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading