diff --git a/.changeset/early-pears-remember.md b/.changeset/early-pears-remember.md new file mode 100644 index 0000000000..fe453e01cb --- /dev/null +++ b/.changeset/early-pears-remember.md @@ -0,0 +1,12 @@ +--- +'@graphiql/toolkit': major +'@graphiql/react': patch +--- + +`graphiql-toolkit` now accepts `HeadersInit` input. + +`graphiql-react` has internal type changes to support this. + +BREAKING CHANGE: + +Because `graphiql-toolkit` functions now accept HeadersInit where previously a partially-wider type of `Record` was accepted, there is a technical backwards incompatibility. This new stricter type could, for example, cause your project to fail type-checking after this upgrade. At runtime, nothing should change, since if you weren't already using `string` typed value headers, then they were being coerced implicitly. In practice, this should only serve to marginally improve your code, with trivial effort. diff --git a/packages/graphiql-react/src/execution.tsx b/packages/graphiql-react/src/execution.tsx index 3c13d335fa..2db8f8f73c 100644 --- a/packages/graphiql-react/src/execution.tsx +++ b/packages/graphiql-react/src/execution.tsx @@ -147,7 +147,7 @@ export function ExecutionContextProvider({ } const headersString = headerEditor?.getValue(); - let headers: Record | undefined; + let headers: Record | undefined; try { headers = tryParseJsonObject({ json: headersString, diff --git a/packages/graphiql-react/src/schema.tsx b/packages/graphiql-react/src/schema.tsx index 20346f3e90..3a7d8791e9 100644 --- a/packages/graphiql-react/src/schema.tsx +++ b/packages/graphiql-react/src/schema.tsx @@ -391,7 +391,7 @@ function useIntrospectionQuery({ } function parseHeaderString(headersString?: string) { - let headers: Record | null = null; + let headers: Record | null = null; let isValidJSON = true; try { diff --git a/packages/graphiql-toolkit/package.json b/packages/graphiql-toolkit/package.json index 1602a314e5..78f8dc9bc8 100644 --- a/packages/graphiql-toolkit/package.json +++ b/packages/graphiql-toolkit/package.json @@ -23,7 +23,7 @@ "dev": "tsup --watch", "prebuild": "yarn types:check", "types:check": "tsc --noEmit", - "test": "vitest run" + "test": "vitest" }, "dependencies": { "@n1ru4l/push-pull-async-iterable-iterator": "^3.1.0", diff --git a/packages/graphiql-toolkit/src/create-fetcher/__tests__/_helpers.ts b/packages/graphiql-toolkit/src/create-fetcher/__tests__/_helpers.ts new file mode 100644 index 0000000000..b0a87bc36d --- /dev/null +++ b/packages/graphiql-toolkit/src/create-fetcher/__tests__/_helpers.ts @@ -0,0 +1,36 @@ +/* eslint-disable */ + +import { Mock, it as itBase } from 'vitest'; + +export const test = itBase.extend<{ + fetch: Mock; + graphqlWs: { + createClient: Mock< + (parameters: { connectionParams: Record }) => any + >; + }; +}>({ + // @ts-expect-error fixme + fetch: async ({}, use) => { + const originalFetch = globalThis.fetch; + const mock = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ data: {} }))); + globalThis.fetch = mock; + await use(fetch); + globalThis.fetch = originalFetch; + }, + graphqlWs: async ({}, use) => { + const graphqlWsExports = { + createClient: vi.fn(() => { + return { + subscribe: vi.fn(), + }; + }), + }; + vi.doMock('graphql-ws', () => { + return graphqlWsExports; + }); + await use(graphqlWsExports); + }, +}); diff --git a/packages/graphiql-toolkit/src/create-fetcher/__tests__/createFetcher.spec.ts b/packages/graphiql-toolkit/src/create-fetcher/__tests__/createFetcher.spec.ts new file mode 100644 index 0000000000..71a99083a1 --- /dev/null +++ b/packages/graphiql-toolkit/src/create-fetcher/__tests__/createFetcher.spec.ts @@ -0,0 +1,77 @@ +/* eslint-disable */ + +import { parse } from 'graphql'; +import { createGraphiQLFetcher } from '../createFetcher'; +import { test } from './_helpers'; + +interface TestCase { + constructor: HeadersInit; + send: HeadersInit; + expected: Record; +} + +const H = Headers; +const cases: TestCase[] = [ + // --- levels merge + { constructor: { a:'1' } , send: { b:'2' } , expected: { a:'1', b:'2' } }, + { constructor: [['a','1']] , send: [['b','2']] , expected: { a:'1', b:'2' } }, + { constructor: new H({a:'1'}) , send: new H({b:'2'}) , expected: { a:'1', b:'2' } }, + // --- send level takes precedence + { constructor: { a:'1' } , send: { a:'2' } , expected: { a:'2' } }, + { constructor: [['a','1']] , send: [['a','2']] , expected: { a:'2' } }, + { constructor: new H({a:'1'}) , send: new H({a:'2'}) , expected: { a:'2' } }, +]; // prettier-ignore + +describe('accepts HeadersInit on constructor and send levels, send taking precedence', () => { + test.for(cases)('%j', async (_case, { fetch }) => { + const fetcher = createGraphiQLFetcher({ + url: 'https://foobar', + enableIncrementalDelivery: false, + headers: _case.constructor, + }); + await fetcher({ query: '' }, { headers: _case.send }); + // @ts-expect-error + const requestHeaders = Object.fromEntries(new Headers(fetch.mock.calls[0]?.[1]?.headers ?? {}).entries()); // prettier-ignore + expect(fetch).toBeCalledTimes(1); + expect(requestHeaders).toMatchObject(_case.expected); + }); + + test.for(cases)('incremental delivery: %j', async (_case, { fetch }) => { + const fetcher = createGraphiQLFetcher({ + url: 'https://foobar', + enableIncrementalDelivery: true, + headers: _case.constructor, + }); + const result = await fetcher({ query: '' }, { headers: _case.send }); + // TODO: Improve types to indicate that result is AsyncIterable when enableIncrementalDelivery is true + await drainAsyncIterable(result as AsyncIterable); + // @ts-expect-error + const requestHeaders = Object.fromEntries(new Headers(fetch.mock.calls[0]?.[1]?.headers ?? {}).entries()); // prettier-ignore + expect(fetch).toBeCalledTimes(1); + expect(requestHeaders).toMatchObject(_case.expected); + }); + + test.for(cases)('subscription: %j', async (_case, { graphqlWs }) => { + const fetcher = createGraphiQLFetcher({ + url: 'https://foobar', + headers: _case.constructor, + subscriptionUrl: 'wss://foobar', + }); + await fetcher({ query: '', operationName:'foo' }, { headers: _case.send, documentAST: parse('subscription foo { bar }') }); // prettier-ignore + const connectionParams = graphqlWs.createClient.mock.calls[0]?.[0]?.connectionParams ?? {}; // prettier-ignore + expect(graphqlWs.createClient).toBeCalledTimes(1); + expect(connectionParams).toMatchObject(_case.expected); + }); +}); + +// ------------------------------------------------------------------------------------------------- +// Helpers +// ------------------------------------------------------------------------------------------------- + +const drainAsyncIterable = async (iterable: AsyncIterable) => { + const result: any[] = []; + for await (const item of iterable) { + result.push(item); + } + return result; +}; diff --git a/packages/graphiql-toolkit/src/create-fetcher/createFetcher.ts b/packages/graphiql-toolkit/src/create-fetcher/createFetcher.ts index b90c65ee83..3635c33902 100644 --- a/packages/graphiql-toolkit/src/create-fetcher/createFetcher.ts +++ b/packages/graphiql-toolkit/src/create-fetcher/createFetcher.ts @@ -13,11 +13,11 @@ import { * - optionally supports graphql-ws or ` */ export function createGraphiQLFetcher(options: CreateFetcherOptions): Fetcher { - const httpFetch = - options.fetch || (typeof window !== 'undefined' && window.fetch); + const httpFetch = options.fetch ?? globalThis.fetch; if (!httpFetch) { throw new Error('No valid fetcher implementation available'); } + options.enableIncrementalDelivery = options.enableIncrementalDelivery !== false; // simpler fetcher for schema requests @@ -29,6 +29,7 @@ export function createGraphiQLFetcher(options: CreateFetcherOptions): Fetcher { return async (graphQLParams, fetcherOpts) => { if (graphQLParams.operationName === 'IntrospectionQuery') { + // todo test this return (options.schemaFetcher || simpleFetcher)( graphQLParams, fetcherOpts, diff --git a/packages/graphiql-toolkit/src/create-fetcher/lib.ts b/packages/graphiql-toolkit/src/create-fetcher/lib.ts index fc8e8e1fc8..829bdf48f3 100644 --- a/packages/graphiql-toolkit/src/create-fetcher/lib.ts +++ b/packages/graphiql-toolkit/src/create-fetcher/lib.ts @@ -1,3 +1,9 @@ +// todo +// Current TS Config target does not support `Headers.entries()` method. +// However, it is reported as "widely available", and so should be fine to use. +// @see https://developer.mozilla.org/en-US/docs/Web/API/Headers/entries +// We currently use ts-expect-error at several places to allow this. + import { DocumentNode, visit } from 'graphql'; import { meros } from 'meros'; import type { @@ -57,14 +63,17 @@ export const isSubscriptionWithName = ( export const createSimpleFetcher = (options: CreateFetcherOptions, httpFetch: typeof fetch): Fetcher => async (graphQLParams: FetcherParams, fetcherOpts?: FetcherOpts) => { + const headers = new Headers({ + 'content-type': 'application/json', + // @ts-expect-error: todo enable ES target that has entries on headers + ...Object.fromEntries(new Headers(options.headers ?? {}).entries()), + // @ts-expect-error: todo enable ES target that has entries on headers + ...Object.fromEntries(new Headers(fetcherOpts?.headers ?? {}).entries()), + }); const data = await httpFetch(options.url, { method: 'POST', body: JSON.stringify(graphQLParams), - headers: { - 'content-type': 'application/json', - ...options.headers, - ...fetcherOpts?.headers, - }, + headers, }); return data.json(); }; @@ -141,17 +150,18 @@ export const createMultipartFetcher = ( httpFetch: typeof fetch, ): Fetcher => async function* (graphQLParams: FetcherParams, fetcherOpts?: FetcherOpts) { + const headers = new Headers({ + 'content-type': 'application/json', + accept: 'application/json, multipart/mixed', + // @ts-expect-error: todo enable ES target that has entries on headers + ...Object.fromEntries(new Headers(options.headers ?? {}).entries()), + // @ts-expect-error: todo enable ES target that has entries on headers + ...Object.fromEntries(new Headers(fetcherOpts?.headers ?? {}).entries()), + }); const response = await httpFetch(options.url, { method: 'POST', body: JSON.stringify(graphQLParams), - headers: { - 'content-type': 'application/json', - accept: 'application/json, multipart/mixed', - ...options.headers, - // allow user-defined headers to override - // the static provided headers - ...fetcherOpts?.headers, - }, + headers, }).then(r => meros>(r, { multiple: true, @@ -187,9 +197,15 @@ export async function getWsFetcher( return createWebsocketsFetcherFromClient(options.wsClient); } if (options.subscriptionUrl) { + const headers = { + // @ts-expect-error: todo enable ES target that has entries on headers + ...Object.fromEntries(new Headers(options?.headers ?? {}).entries()), + // @ts-expect-error: todo enable ES target that has entries on headers + ...Object.fromEntries(new Headers(fetcherOpts?.headers ?? {}).entries()), + }; return createWebsocketsFetcherFromUrl(options.subscriptionUrl, { ...options.wsConnectionParams, - ...fetcherOpts?.headers, + ...headers, }); } const legacyWebsocketsClient = options.legacyClient || options.legacyWsClient; diff --git a/packages/graphiql-toolkit/src/create-fetcher/types.ts b/packages/graphiql-toolkit/src/create-fetcher/types.ts index 9ae06a67be..fa30ce8739 100644 --- a/packages/graphiql-toolkit/src/create-fetcher/types.ts +++ b/packages/graphiql-toolkit/src/create-fetcher/types.ts @@ -31,7 +31,7 @@ export type FetcherParams = { }; export type FetcherOpts = { - headers?: { [key: string]: any }; + headers?: HeadersInit; documentAST?: DocumentNode; }; @@ -104,7 +104,7 @@ export interface CreateFetcherOptions { * If you enable the headers editor and the user provides * A header you set statically here, it will be overridden by their value. */ - headers?: Record; + headers?: HeadersInit; /** * Websockets connection params used when you provide subscriptionUrl. graphql-ws `ClientOptions.connectionParams` */