Skip to content
8 changes: 6 additions & 2 deletions .changeset/transport-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@
'graphiql': minor
---

Add a structured `Transport` API alongside the existing `Fetcher`. `createTransport({...})` performs the GraphQL request and returns a `TransportResponse` carrying the real HTTP wire metadata (status, headers, timing, size) for queries, mutations, subscriptions, and incremental delivery, so the response pane can surface those values directly instead of fabricating them. That metadata is there even when the response body isn't valid JSON (an HTML error page from a proxy, a plain-text 401), so a broken response still shows its real status code instead of a generic error. `<GraphiQL>` accepts a new `transport` prop, mutually exclusive with `fetcher` at the type level.
Add a structured `Transport` API alongside the existing `Fetcher`. `createTransport({...})` performs the GraphQL request and returns a `TransportResponse` carrying the real HTTP wire metadata (method, status, headers, timing, size) for queries, mutations, subscriptions, and incremental delivery, so the response pane can surface those values directly instead of fabricating them. That metadata is there even when the response body isn't valid JSON (an HTML error page from a proxy, a plain-text 401), so a broken response still shows its real status code instead of a generic error. `<GraphiQL>` accepts a new `transport` prop, mutually exclusive with `fetcher` at the type level.

Transports support GET, POST, and the [HTTP `QUERY`](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/) method per the GraphQL over HTTP spec. Pass `method` / `supportedMethods` to choose; GET encodes the query into the URL with no body, `QUERY` sends a JSON body but is safe and idempotent, and mutations are always sent over POST (or blocked when POST is unavailable). `Transport` exposes `url`, `method`, `supportedMethods`, and an optional `setMethod`, and the top bar shows the active method and endpoint with an inline switcher that cycles through the supported methods. Every request, incremental delivery on or off, sends `application/graphql-response+json` in its `accept` header alongside `application/json`, so spec-compliant servers don't fall back to legacy response semantics. Subscriptions require an explicit `subscriptionClient` satisfying a small `SubscriptionClient` contract: a single `.iterate(request)` method that `graphql-ws` v6 and `graphql-sse` clients meet directly. The low-level `simpleHttpTransport` and `multipartHttpTransport` primitives also accept an optional `method`.
Transports support GET, POST, and the [HTTP `QUERY`](https://www.rfc-editor.org/rfc/rfc10008.html) method. Queries automatically prefer QUERY, then POST, then GET. Pass `method` to override query selection for testing, or call the optional `setMethod`; calling `setMethod()` without an argument restores automatic selection. GET encodes the query, operation name, variables, and extensions into the URL with no body. QUERY sends a JSON body but is safe and idempotent. Mutations always require POST, and operations whose type cannot be determined also require POST. If POST is unavailable, they are rejected without sending. An unavailable query override also rejects without silently changing methods.

By default, `createTransport` discovers supported HTTP methods with an `OPTIONS` request before its first HTTP operation, using the effective request headers and reading `Allow`. Discovery is shared by concurrent requests and repeated when headers change. If discovery fails, times out, or returns no usable `Allow` header, the transport falls back to POST (or the configured `method` override); an explicit advertisement with no usable methods blocks execution. Pass `supportedMethods` to bypass discovery for endpoints with known capabilities. For example, `method: 'GET', supportedMethods: ['GET', 'POST']` tests GET queries while keeping POST available for mutations. Cross-origin servers must permit the OPTIONS request and expose `Allow` through `Access-Control-Expose-Headers`.

The top bar displays the endpoint. The response pane displays the actual HTTP method from `TransportResponse.method` alongside status, timing, and size; it omits the method when a transport cannot report it. GraphiQL delegates routing to the transport without a method switcher or capability subscription. `Transport` exposes `url`, `method`, `supportedMethods`, and an optional `setMethod` for programmatic overrides. Every HTTP operation, incremental delivery on or off, sends `application/graphql-response+json` in its `accept` header alongside `application/json`. Subscriptions require an explicit `subscriptionClient` satisfying a small `SubscriptionClient` contract: a single `.iterate(request)` method that `graphql-ws` v6 and `graphql-sse` clients meet directly. The low-level `simpleHttpTransport` and `multipartHttpTransport` primitives also accept an optional `method`.

`TransportRequest` carries `extensions` for GraphQL-over-HTTP extensions such as automatic persisted queries (encoded into the URL for `GET`, included in the JSON body for `POST` and `QUERY`), and `signal`, an `AbortSignal` that cancels an in-flight query or mutation. Stopping a running query or mutation aborts the request; stopping a subscription closes the underlying socket or SSE connection. `TransportResponse.ok` reflects both layers: the HTTP status and the absence of top-level GraphQL errors, so a 401 or 500 is never `ok: true` just because its body happens to parse as JSON with no `errors`.

Expand Down
13 changes: 13 additions & 0 deletions .changeset/transport-method-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@graphiql/toolkit': patch
'@graphiql/react': patch
'graphiql': patch
---

Discover HTTP methods automatically when `createTransport` is called without `supportedMethods`. Previous RCs treated an omitted `supportedMethods` as `['POST']` and exposed a method chip and switcher in the top bar. The transport now sends an OPTIONS request without a body before its first HTTP operation, reads `Allow`, and automatically selects QUERY, then POST, then GET for queries. The top bar method controls are removed. The response pane instead shows the actual request method from the optional `TransportResponse.method` field alongside timing and size, including for HTTP error responses and incremental chunks. WebSocket events and custom transports without this metadata omit the method.

Discovery uses the effective request headers, is shared by concurrent requests, and runs again when headers change. If OPTIONS fails, times out after two seconds, or returns no usable `Allow` header, execution falls back to POST (or the configured `method` override). A valid advertisement containing no usable methods blocks HTTP execution. Mutations always require POST; GraphiQL delegates routing to the transport from Run, the operation dropdown, and the keyboard shortcut. An operation whose type cannot be determined also requires POST. If POST is unavailable, these operations fail without sending.

Pass `method: 'GET'` to override automatic query selection for testing. GET encodes queries, operation names, variables, and extensions in the URL without a body; mutations still require POST. An override that discovery excludes fails without silently choosing another method. `transport.setMethod?.('GET')` changes the override for subsequent queries, and `transport.setMethod?.()` restores automatic selection. Pass `supportedMethods: ['POST']` to preserve the previous POST-only default and skip OPTIONS, or use `method: 'GET', supportedMethods: ['GET', 'POST']` to test GET queries and POST mutations without discovery. If discovery fails with a configured GET override and no explicit capabilities, only GET is assumed available.

For cross-origin discovery, the server must permit OPTIONS and expose `Allow` through `Access-Control-Expose-Headers`. This discovers methods only, not QUERY request formats through `Accept-Query`, and does not retry operations with another method. Custom transports own routing and must prevent mutations over GET and QUERY. GraphiQL reads metadata from each response without subscribing to transport capability changes.
32 changes: 22 additions & 10 deletions docs/migration/graphiql-6.0.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,32 +365,43 @@ Use `onResponse(response => { ... })` to observe each `TransportResponse`, inclu

### Request methods

`createTransport` supports three HTTP methods:
`createTransport` automatically selects the first supported method in this order for queries:

- `POST` is the default. It sends queries and mutations in a JSON request body.
- `GET` sends queries in the URL without a request body.
- [`QUERY`](https://datatracker.ietf.org/doc/draft-ietf-httpbis-safe-method-w-body/) sends queries in a JSON request body but is defined as safe and idempotent.
1. [`QUERY`](https://www.rfc-editor.org/rfc/rfc10008.html) sends a JSON request body and is defined as safe and idempotent.
2. `POST` sends a JSON request body.
3. `GET` sends the query, operation name, variables, and extensions in the URL without a request body.

> **Note:** At the time of writing, `QUERY` is a very new HTTP method and is unlikely to be supported by your GraphQL server. Verify support across your server and any intervening proxies before enabling it.
Before its first HTTP operation, including introspection, the transport discovers available methods with an `OPTIONS` request without a body. It reads the endpoint's `Allow` header using the effective request headers, including changes made by `onBeforeSend` hooks. Concurrent operations share discovery; changing the headers triggers it again. If you supply a schema, discovery waits until you execute an HTTP operation.

Use `method` to set the initial method and `supportedMethods` to list the methods users can select:
If discovery fails, exceeds two seconds, or returns no usable `Allow` header, the transport falls back to POST (or the configured `method` override). A valid advertisement containing no usable methods blocks HTTP requests. Discovery does not automatically retry operations or negotiate QUERY request formats through `Accept-Query`.

For cross-origin discovery, the server must permit OPTIONS and expose `Allow` with `Access-Control-Expose-Headers: Allow`. Browser preflight and its `Access-Control-Allow-Methods` header do not replace endpoint discovery.

Use `method` to override the automatic query method for testing. The override fails if discovery says the method is unavailable. To skip discovery, also pass a nonempty `supportedMethods` array listing the endpoint's capabilities:

```ts
const transport = createTransport({
url: 'https://my.endpoint/graphql',
method: 'POST',
supportedMethods: ['POST', 'GET', 'QUERY'],
method: 'GET',
supportedMethods: ['GET', 'POST'],
});

transport.setMethod?.('POST'); // Override subsequent queries.
transport.setMethod?.(); // Restore automatic selection (POST for this endpoint).
```

Direct calls to `transport.send()` route mutations through `POST`, even when `GET` or `QUERY` is selected. If `POST` isn't included in `supportedMethods`, the transport rejects the mutation.
This example sends queries through GET and mutations through POST. Omitting `method` would prefer POST over GET. If discovery is unavailable with `method: 'GET'` and no explicit capabilities, only GET is assumed available; configure `supportedMethods: ['GET', 'POST']` when both methods are known to work.

In GraphiQL, select **POST** before running a mutation. With **GET** or **QUERY** selected, Run is disabled with the explanation **Mutations can only be sent via POST**. The operation dropdown and keyboard shortcut apply the same restriction. GraphiQL keeps your selected method until you change it. When more than one method is configured, the top bar's method control cycles through `supportedMethods`; for a blocked mutation, selecting the control chooses **POST** when supported.
Mutations always require POST, including operations started from GraphiQL's Run button, operation dropdown, or keyboard shortcut. If POST is unavailable, the transport rejects the mutation without sending it. Operations whose type cannot be determined also require POST. Discovery uses the current request headers before making this decision.

GraphiQL's top bar shows the endpoint without a method chip or switcher. The response pane displays `TransportResponse.method` alongside timing and size, so it shows the actual HTTP method for that response. This field is optional and is omitted for WebSocket events or custom transports that cannot report it. GraphiQL delegates HTTP method policy to the transport; custom transports must also keep mutations off GET and QUERY.

### Custom transports (FAQ)

If you hand-rolled a `Fetcher` rather than using `createGraphiQLFetcher`, migrate by implementing the `Transport` interface directly. A `Transport` declares its endpoint `url`, active `method`, `supportedMethods`, and a `send(request)` method. For queries and mutations it returns a `Promise<TransportResponse>`; for subscriptions and incremental delivery it returns an `AsyncIterable<TransportResponse>`, one entry per event or chunk.

Include the actual HTTP method in each `TransportResponse.method` when your transport can observe it. GraphiQL displays response metadata directly; it does not discover capabilities or subscribe to method changes on custom transports.

**Before (custom fetcher):**

```ts
Expand Down Expand Up @@ -450,6 +461,7 @@ const transport: Transport = {
});
return {
ok: response.ok && !body.errors?.length,
method: 'POST',
status: response.status,
statusText: response.statusText,
headers,
Expand Down
5 changes: 2 additions & 3 deletions packages/graphiql-e2e/cypress/e2e/keyboard.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ describe('keyboard navigation', () => {
cy.get('.graphiql-query-editor .view-lines').should('exist');
});

it('Tab from a fresh load lands on the top bar method chip, and Shift+Tab returns to the page', () => {
it('Tab from a fresh load lands on Run, and Shift+Tab returns to the page', () => {
cy.get('.graphiql-top-bar-brand').realClick();
cy.realPress('Tab');
cy.focused().should('have.class', 'graphiql-top-bar-method-toggle');
cy.focused().should('have.attr', 'aria-label', 'Run operation');
// The brand mark isn't itself focusable, so Shift+Tab from the first
// real stop leaves the document (nothing before it in tab order).
cy.realPress(['Shift', 'Tab']);
Expand All @@ -73,7 +73,6 @@ describe('keyboard navigation', () => {
// toolbar changes while still proving each segment is keyboard-reachable
// in order.
const TOP_BAR_AND_RAIL_LABELS = [
{ className: 'graphiql-top-bar-method-toggle' },
{ ariaLabel: 'Run operation' },
{ ariaLabel: 'Show Documentation Explorer' },
{ ariaLabel: 'Show History' },
Expand Down
88 changes: 62 additions & 26 deletions packages/graphiql-e2e/cypress/e2e/transport.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* The E2E app in `packages/graphiql-e2e/src/e2e.ts` uses `createTransport` and the
* `<GraphiQL transport={...}>` prop. These tests confirm that the response pane
* header shows real wire metadata from the actual HTTP response (status code,
* timing, response size) and that the upgrade banner is NOT shown because
* method, timing, response size) and that the upgrade banner is NOT shown because
* `transport` is in use, not `fetcher`.
*/

Expand All @@ -23,6 +23,32 @@ describe('Transport API + response pane header', () => {
);
});

it('Shows the actual POST method with no toolbar method control', () => {
cy.visitGraphiQL({ query: testQuery });
cy.clickExecuteQuery();
cy.get('.graphiql-response-method').should('have.text', 'POST');
cy.get('.graphiql-top-bar-method-toggle').should('not.exist');
cy.get('.graphiql-top-bar-method-label').should('not.exist');
});

it('Discovers a GET-only endpoint and displays the actual GET method', () => {
cy.intercept('OPTIONS', '/graphql', {
statusCode: 204,
headers: { Allow: 'GET' },
}).as('discovery');
cy.intercept('GET', '/graphql*', request => {
if (new URL(request.url).searchParams.get('operationName') === 'Read') {
request.alias = 'read';
}
});
cy.visitGraphiQL({ query: 'query Read { __typename }' });
cy.wait('@discovery');
cy.clickExecuteQuery();
cy.wait('@read').its('request.method').should('eq', 'GET');
cy.get('.graphiql-response-method').should('have.text', 'GET');
cy.get('.result-window').should('contain.text', '__typename');
});

it('Shows a real timing badge in ms', () => {
cy.visitGraphiQL({ query: testQuery });
cy.clickExecuteQuery();
Expand Down Expand Up @@ -58,39 +84,49 @@ describe('Transport API + response pane header', () => {
});
});

describe('Manual POST selection for mutations', () => {
it('keeps GET selected and blocks button, dropdown, and shortcut execution until POST is selected', () => {
const mutationRequests: unknown[] = [];
cy.intercept('POST', '/graphql', request => {
if (request.body.operationName === 'Change') {
mutationRequests.push(request.body);
describe('Transport routing for mutations', () => {
it('executes mutations through POST from the button, dropdown, and shortcut', () => {
const mutationMethods: string[] = [];
cy.intercept('/graphql*', request => {
const operationName =
request.method === 'GET'
? new URL(request.url).searchParams.get('operationName')
: request.body?.operationName;
if (operationName === 'Change') {
mutationMethods.push(request.method);
request.alias = 'mutation';
}
}).as('graphql');
});
cy.visitGraphiQL({
query: 'query Read { __typename }\nmutation Change { __typename }\n',
});
cy.get('.graphiql-top-bar-method-toggle').click();
cy.get('.graphiql-top-bar-method-toggle').click();
cy.get('.graphiql-top-bar-method-toggle').should('have.text', 'GET');
cy.activateOperation('Change');
cy.get('[aria-label="Run operation"]').should('be.disabled');

cy.get('[aria-label="Run operation"]').should('be.enabled').click();
cy.wait('@mutation').its('request.method').should('eq', 'POST');
cy.get('.result-window').should('contain.text', 'MutationType');
cy.get('.graphiql-response-method').should('have.text', 'POST');

cy.activateOperation('Read');
cy.get('[aria-label="Choose operation to run"]').click();
cy.contains('[role="menuitem"]', 'Change').should(
'have.attr',
'aria-disabled',
'true',
);
cy.realPress('Escape');
cy.contains('[role="menuitem"]', 'Change')
.should(item => {
expect(item).not.to.have.attr('aria-disabled');
})
.click();
cy.wait('@mutation').its('request.method').should('eq', 'POST');
cy.get('.result-window').should('contain.text', 'MutationType');
cy.get('.graphiql-response-method').should('have.text', 'POST');

cy.activateOperation('Change');
cy.get('[aria-label="Run operation"]').should('be.enabled');
cy.get('.graphiql-query-editor textarea').focus();
cy.realPress([Cypress.platform === 'darwin' ? 'Meta' : 'Control', 'Enter']);
cy.get('[aria-label="Run operation"]').should('be.disabled');
cy.get('.graphiql-top-bar-method-toggle').should('have.text', 'GET');
cy.then(() => expect(mutationRequests).to.have.length(0));

cy.get('.graphiql-top-bar-method-toggle').click();
cy.get('.graphiql-top-bar-method-toggle').should('have.text', 'POST');
cy.get('[aria-label="Run operation"]').should('be.enabled').click();
cy.wait('@mutation').its('request.method').should('eq', 'POST');
cy.get('.result-window').should('contain.text', 'MutationType');
cy.then(() => expect(mutationRequests).to.have.length(1));
cy.get('.graphiql-response-method').should('have.text', 'POST');
cy.then(() =>
expect(mutationMethods).to.deep.equal(['POST', 'POST', 'POST']),
);
});
});
1 change: 0 additions & 1 deletion packages/graphiql-e2e/src/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,6 @@ const root = ReactDOM.createRoot(document.getElementById('graphiql')!);
const props: ComponentProps<typeof GraphiQL> = {
transport: createTransport({
url: getSchemaUrl(),
supportedMethods: ['GET', 'POST', 'QUERY'],
subscriptionClient: isLocal()
? createClient({
url: `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/subscriptions`,
Expand Down
Loading
Loading