diff --git a/.changeset/empty-rockets-smell.md b/.changeset/empty-rockets-smell.md new file mode 100644 index 0000000000..d06941a545 --- /dev/null +++ b/.changeset/empty-rockets-smell.md @@ -0,0 +1,18 @@ +--- +'hive': minor +--- + +You can now set HTTP headers in your [Laboratory Preflight Script](https://the-guild.dev/graphql/hive/docs/dashboard/laboratory/preflight-scripts). Every time you run a request from Laboratory, your preflight headers, if any, will be merged into the request before it is sent. + +You achieve this by interacting with the [`Headers`](https://developer.mozilla.org/docs/web/api/headers) instance newly available at `lab.request.headers`. For example, this script would would add a `foo` header with the value `bar` to every Laboratory request. + +```ts +lab.request.headers.set('foo', 'bar') +``` + +A few notes about how headers are merged: + +1. Unlike static headers, preflight headers do not receive environment variable substitutions on their values. +2. Preflight headers take precedence, overwriting any same-named headers already in the Laboratory request. + +Documentation for this new feature is available at https://the-guild.dev/graphql/hive/docs/dashboard/laboratory/preflight-scripts#http-headers. diff --git a/cypress/e2e/laboratory-preflight-script.cy.ts b/cypress/e2e/laboratory-preflight-script.cy.ts index 6529499c00..c935988df8 100644 --- a/cypress/e2e/laboratory-preflight-script.cy.ts +++ b/cypress/e2e/laboratory-preflight-script.cy.ts @@ -1,5 +1,21 @@ import { dedent } from '../support/testkit'; +const selectors = { + buttonModalCy: 'preflight-script-modal-button', + buttonToggleCy: 'toggle-preflight-script', + buttonHeaders: '[data-name="headers"]', + headersEditor: { + textArea: '.graphiql-editor-tool .graphiql-editor:last-child textarea', + }, + graphiql: { + buttonExecute: '.graphiql-execute-button', + }, + + modal: { + buttonSubmitCy: 'preflight-script-modal-submit', + }, +}; + beforeEach(() => { cy.clearLocalStorage().then(async () => { cy.task('seedTarget').then(({ slug, refreshToken }: any) => { @@ -186,6 +202,81 @@ throw new TypeError('Test')`, }); describe('Execution', () => { + it('result.request.headers are added to the graphiql request base headers', () => { + // Setup Preflight Script + const preflightHeaders = { + foo: 'bar', + }; + cy.dataCy(selectors.buttonToggleCy).click(); + cy.dataCy(selectors.buttonModalCy).click(); + setEditorScript(`lab.request.headers.append('foo', '${preflightHeaders.foo}')`); + cy.dataCy(selectors.modal.buttonSubmitCy).click(); + // Run GraphiQL + cy.intercept({ headers: preflightHeaders }).as('request'); + cy.get(selectors.graphiql.buttonExecute).click(); + cy.wait('@request'); + }); + + it('result.request.headers take precedence over graphiql request base headers', () => { + // Integrity Check: Ensure the header we think we're overriding is actually there to override. + // We achieve this by asserting a sent GraphiQL request includes the certain header and assume + // if its there once its there every time. + const baseHeaders = { + accept: 'application/json, multipart/mixed', + }; + cy.intercept({ headers: baseHeaders }).as('integrityCheck'); + cy.get(selectors.graphiql.buttonExecute).click(); + cy.wait('@integrityCheck'); + // Setup Preflight Script + const preflightHeaders = { + accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8', + }; + cy.dataCy(selectors.buttonToggleCy).click(); + cy.dataCy(selectors.buttonModalCy).click(); + setEditorScript(`lab.request.headers.append('accept', '${preflightHeaders.accept}')`); + cy.dataCy(selectors.modal.buttonSubmitCy).click(); + // Run GraphiQL + cy.intercept({ headers: preflightHeaders }).as('request'); + cy.get(selectors.graphiql.buttonExecute).click(); + cy.wait('@request'); + }); + + it('result.request.headers are NOT substituted with environment variables', () => { + const barEnVarInterpolation = '{{bar}}'; + // Setup Static Headers + const staticHeaders = { + foo_static: barEnVarInterpolation, + }; + cy.get(selectors.buttonHeaders).click(); + cy.get(selectors.headersEditor.textArea).type(JSON.stringify(staticHeaders), { + force: true, + parseSpecialCharSequences: false, + }); + // Setup Preflight Script + const environmentVariables = { + bar: 'BAR_VALUE', + }; + const preflightHeaders = { + foo_preflight: barEnVarInterpolation, + }; + cy.dataCy(selectors.buttonToggleCy).click(); + cy.dataCy(selectors.buttonModalCy).click(); + setEditorScript(` + lab.environment.set('bar', '${environmentVariables.bar}') + lab.request.headers.append('foo_preflight', '${preflightHeaders.foo_preflight}') + `); + cy.dataCy(selectors.modal.buttonSubmitCy).click(); + // Run GraphiQL + cy.intercept({ + headers: { + ...preflightHeaders, + foo_static: environmentVariables.bar, + }, + }).as('request'); + cy.get(selectors.graphiql.buttonExecute).click(); + cy.wait('@request'); + }); + it('header placeholders are substituted with environment variables', () => { cy.dataCy('toggle-preflight-script').click(); cy.get('[data-name="headers"]').click(); diff --git a/packages/web/app/src/lib/kit/helpers.ts b/packages/web/app/src/lib/kit/helpers.ts new file mode 100644 index 0000000000..bf375d2e56 --- /dev/null +++ b/packages/web/app/src/lib/kit/helpers.ts @@ -0,0 +1,10 @@ +export const tryOr = <$PrimaryResult, $FallbackResult>( + fn: () => $PrimaryResult, + fallback: () => $FallbackResult, +): $PrimaryResult | $FallbackResult => { + try { + return fn(); + } catch { + return fallback(); + } +}; diff --git a/packages/web/app/src/lib/kit/index.ts b/packages/web/app/src/lib/kit/index.ts new file mode 100644 index 0000000000..87c64a4bfd --- /dev/null +++ b/packages/web/app/src/lib/kit/index.ts @@ -0,0 +1,6 @@ +// eslint-disable-next-line import/no-self-import +export * as Kit from './index'; + +export * from './never'; +export * from './types/headers'; +export * from './helpers'; diff --git a/packages/web/app/src/lib/kit/never.ts b/packages/web/app/src/lib/kit/never.ts new file mode 100644 index 0000000000..1b7a40c724 --- /dev/null +++ b/packages/web/app/src/lib/kit/never.ts @@ -0,0 +1,15 @@ +/** + * This case is impossible. + * If it happens, then that means there is a bug in our code. + */ +export const neverCase = (value: never): never => { + never({ type: 'case', value }); +}; + +/** + * This code cannot be reached. + * If it is reached, then that means there is a bug in our code. + */ +export const never: (context?: object) => never = context => { + throw new Error('Something that should be impossible happened', { cause: context }); +}; diff --git a/packages/web/app/src/lib/kit/types/headers.ts b/packages/web/app/src/lib/kit/types/headers.ts new file mode 100644 index 0000000000..177db6a4bf --- /dev/null +++ b/packages/web/app/src/lib/kit/types/headers.ts @@ -0,0 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/no-namespace +export namespace Headers { + export type Encoded = [name: string, value: string][]; +} diff --git a/packages/web/app/src/lib/preflight-sandbox/graphiql-plugin.tsx b/packages/web/app/src/lib/preflight-sandbox/graphiql-plugin.tsx index 4f9ac0ce41..307ed7058a 100644 --- a/packages/web/app/src/lib/preflight-sandbox/graphiql-plugin.tsx +++ b/packages/web/app/src/lib/preflight-sandbox/graphiql-plugin.tsx @@ -32,10 +32,16 @@ import { Editor as MonacoEditor, OnMount, type Monaco } from '@monaco-editor/rea import { Cross2Icon, InfoCircledIcon, Pencil1Icon, TriangleRightIcon } from '@radix-ui/react-icons'; import { captureException } from '@sentry/react'; import { useParams } from '@tanstack/react-router'; +import { Kit } from '../kit'; import { cn } from '../utils'; import labApiDefinitionRaw from './lab-api-declaration?raw'; import { IFrameEvents, LogMessage } from './shared-types'; +export type PreflightScriptResultData = Omit< + IFrameEvents.Outgoing.EventData.Result, + 'type' | 'runId' +>; + export const preflightScriptPlugin: GraphiQLPlugin = { icon: () => ( | null { - try { - return JSON.parse(str); - } catch { - return null; - } -} - export const enum PreflightWorkerState { running, ready, @@ -173,9 +171,24 @@ export function usePreflightScript(args: { const currentRun = useRef(null); - async function execute(script = target?.preflightScript?.sourceCode ?? '', isPreview = false) { + async function execute( + script = target?.preflightScript?.sourceCode ?? '', + isPreview = false, + ): Promise { + const resultEnvironmentVariablesDecoded: PreflightScriptResultData['environmentVariables'] = + Kit.tryOr( + () => JSON.parse(latestEnvironmentVariablesRef.current), + () => ({}), + ); + const result: PreflightScriptResultData = { + request: { + headers: [], + }, + environmentVariables: resultEnvironmentVariablesDecoded, + }; + if (isPreview === false && !isPreflightScriptEnabled) { - return safeParseJSON(latestEnvironmentVariablesRef.current); + return result; } const id = crypto.randomUUID(); @@ -201,7 +214,8 @@ export function usePreflightScript(args: { type: IFrameEvents.Incoming.Event.run, id, script, - environmentVariables: (environmentVariables && safeParseJSON(environmentVariables)) || {}, + // Preflight Script has read/write relationship with environment variables. + environmentVariables: result.environmentVariables, } satisfies IFrameEvents.Incoming.EventData, '*', ); @@ -257,16 +271,23 @@ export function usePreflightScript(args: { } if (ev.data.type === IFrameEvents.Outgoing.Event.result) { - const mergedEnvironmentVariables = JSON.stringify( - { - ...safeParseJSON(latestEnvironmentVariablesRef.current), - ...ev.data.environmentVariables, - }, + const mergedEnvironmentVariables = { + ...result.environmentVariables, + ...ev.data.environmentVariables, + }; + result.environmentVariables = mergedEnvironmentVariables; + result.request.headers = ev.data.request.headers; + + // Cause the new state of environment variables to be + // written back to local storage. + const mergedEnvironmentVariablesEncoded = JSON.stringify( + result.environmentVariables, null, 2, ); - setEnvironmentVariables(mergedEnvironmentVariables); - latestEnvironmentVariablesRef.current = mergedEnvironmentVariables; + setEnvironmentVariables(mergedEnvironmentVariablesEncoded); + latestEnvironmentVariablesRef.current = mergedEnvironmentVariablesEncoded; + setLogs(logs => [ ...logs, { @@ -301,7 +322,6 @@ export function usePreflightScript(args: { ]); setFinished(); closedOpenedPrompts(); - return; } @@ -310,6 +330,27 @@ export function usePreflightScript(args: { setLogs(logs => [...logs, log]); return; } + + if (ev.data.type === IFrameEvents.Outgoing.Event.ready) { + console.debug('preflight sandbox graphiql plugin: noop iframe event:', ev.data); + return; + } + + if (ev.data.type === IFrameEvents.Outgoing.Event.start) { + console.debug('preflight sandbox graphiql plugin: noop iframe event:', ev.data); + return; + } + + // Window message events can be emitted from unknowable sources. + // For example when our e2e tests runs within Cypress GUI, we see a `MessageEvent` with `.data` of `{ vscodeScheduleAsyncWork: 3 }`. + // Since we cannot know if the event source is Preflight Script, we cannot perform an exhaustive check. + // + // Kit.neverCase(ev.data); + // + console.debug( + 'preflight sandbox graphiql plugin: An unknown window message event received. Ignoring.', + ev, + ); } window.addEventListener('message', eventHandler); @@ -328,7 +369,8 @@ export function usePreflightScript(args: { window.removeEventListener('message', eventHandler); setState(PreflightWorkerState.ready); - return safeParseJSON(latestEnvironmentVariablesRef.current); + + return result; } catch (err) { if (err instanceof Error) { setLogs(prev => [ @@ -346,7 +388,7 @@ export function usePreflightScript(args: { }, ]); setState(PreflightWorkerState.ready); - return safeParseJSON(latestEnvironmentVariablesRef.current); + return result; } throw err; } diff --git a/packages/web/app/src/lib/preflight-sandbox/lab-api-declaration.ts b/packages/web/app/src/lib/preflight-sandbox/lab-api-declaration.ts index 7e2daf3ab7..80ba87421a 100644 --- a/packages/web/app/src/lib/preflight-sandbox/lab-api-declaration.ts +++ b/packages/web/app/src/lib/preflight-sandbox/lab-api-declaration.ts @@ -8,6 +8,22 @@ // and use Prettier to format it, have syntax highlighting, etc. interface LabAPI { + /** + * Contains aspects of the request that you can manipulate before it is sent. + */ + request: { + /** + * Headers that will be added to the request. They are merged + * using the following rules: + * + * 1. Do *not* interpolate environment variables. + * + * 2. Upon a collision with a base header, this header takes precedence. + * This means that if the base headers contain "foo: bar" and you've added + * "foo: qux" here, the final headers become "foo: qux" (*not* "foo: bar, qux"). + */ + headers: Headers; + }; /** * [CryptoJS](https://cryptojs.gitbook.io/docs) library. */ diff --git a/packages/web/app/src/lib/preflight-sandbox/preflight-script-worker.ts b/packages/web/app/src/lib/preflight-sandbox/preflight-script-worker.ts index 63c4416556..4b9d53f6e7 100644 --- a/packages/web/app/src/lib/preflight-sandbox/preflight-script-worker.ts +++ b/packages/web/app/src/lib/preflight-sandbox/preflight-script-worker.ts @@ -1,9 +1,16 @@ import CryptoJS from 'crypto-js'; import CryptoJSPackageJson from 'crypto-js/package.json'; import { ALLOWED_GLOBALS } from './allowed-globals'; -import { isJSONPrimitive } from './json'; +import { isJSONPrimitive, JSONPrimitive } from './json'; import { LogMessage, WorkerEvents } from './shared-types'; +interface WorkerData { + request: { + headers: Headers; + }; + environmentVariables: Record; +} + /** * Unique id for each prompt request. * Incremented each time a prompt is requested. @@ -45,11 +52,16 @@ async function execute(args: WorkerEvents.Incoming.EventData): Promise { return; } - const { environmentVariables, script } = args; + const { script } = args; - // When running in worker `environmentVariables` will not be a reference to the main thread value - // but sometimes this will be tested outside the worker, so we don't want to mutate the input in that case - const workingEnvironmentVariables = { ...environmentVariables }; + const workerData: WorkerData = { + request: { + headers: new Headers(), + }, + // When running in worker `environmentVariables` will not be a reference to the main thread value + // but sometimes this will be tested outside the worker, so we don't want to mutate the input in that case + environmentVariables: { ...args.environmentVariables }, + }; // generate list of all in scope variables, we do getOwnPropertyNames and `for in` because each contain slightly different sets of keys const allGlobalKeys = Object.getOwnPropertyNames(globalThis); @@ -123,17 +135,20 @@ async function execute(args: WorkerEvents.Incoming.EventData): Promise { }, environment: { get(key: string) { - return Object.freeze(workingEnvironmentVariables[key]); + return Object.freeze(workerData.environmentVariables[key]); }, set(key: string, value: unknown) { const validValue = getValidEnvVariable(value); if (validValue === undefined) { - delete workingEnvironmentVariables[key]; + delete workerData.environmentVariables[key]; } else { - workingEnvironmentVariables[key] = validValue; + workerData.environmentVariables[key] = validValue; } }, }, + request: { + headers: workerData.request.headers, + }, /** * Mimics the `prompt` function in the browser, by sending a message to the main thread * and waiting for a response. @@ -179,9 +194,13 @@ ${script}})()`; }); return; } + sendMessage({ type: WorkerEvents.Outgoing.Event.result, - environmentVariables: workingEnvironmentVariables, + environmentVariables: workerData.environmentVariables, + request: { + headers: Array.from(workerData.request.headers.entries()), + }, }); } diff --git a/packages/web/app/src/lib/preflight-sandbox/preflight-worker-embed.ts b/packages/web/app/src/lib/preflight-sandbox/preflight-worker-embed.ts index 538e581001..43f4d12c84 100644 --- a/packages/web/app/src/lib/preflight-sandbox/preflight-worker-embed.ts +++ b/packages/web/app/src/lib/preflight-sandbox/preflight-worker-embed.ts @@ -1,3 +1,4 @@ +import { Kit } from '../kit'; import PreflightWorker from './preflight-script-worker?worker&inline'; import { IFrameEvents, WorkerEvents } from './shared-types'; @@ -106,6 +107,7 @@ function handleEvent(data: IFrameEvents.Incoming.EventData) { type: IFrameEvents.Outgoing.Event.result, runId, environmentVariables: ev.data.environmentVariables, + request: ev.data.request, }); terminate(); return; @@ -129,6 +131,8 @@ function handleEvent(data: IFrameEvents.Incoming.EventData) { terminate(); return; } + + Kit.neverCase(ev.data); }, ); diff --git a/packages/web/app/src/lib/preflight-sandbox/shared-types.ts b/packages/web/app/src/lib/preflight-sandbox/shared-types.ts index fcbf75cff6..ca86490346 100644 --- a/packages/web/app/src/lib/preflight-sandbox/shared-types.ts +++ b/packages/web/app/src/lib/preflight-sandbox/shared-types.ts @@ -1,5 +1,8 @@ /* eslint-disable @typescript-eslint/no-namespace */ +import { Kit } from '../kit'; +import { JSONPrimitive } from './json'; + type _MessageEvent = MessageEvent; export type LogMessage = { @@ -26,48 +29,54 @@ export namespace IFrameEvents { prompt = 'prompt', } - type ReadyEventData = { - type: Event.ready; - }; - - type StartEventData = { - type: Event.start; - runId: string; - }; - - type LogEventData = { - type: Event.log; - runId: string; - log: LogMessage; - }; - - type ResultEventData = { - type: Event.result; - runId: string; - environmentVariables: Record; - }; - - type ErrorEventData = { - type: Event.error; - runId: string; - error: ErrorMessage; - }; - - type PromptEventData = { - type: Event.prompt; - runId: string; - promptId: number; - message: string; - defaultValue?: string; - }; - - export type EventData = - | ReadyEventData - | StartEventData - | LogEventData - | PromptEventData - | ResultEventData - | ErrorEventData; + export namespace EventData { + export interface Ready { + type: Event.ready; + } + + export interface Start { + type: Event.start; + runId: string; + } + + export interface Log { + type: Event.log; + runId: string; + log: LogMessage; + } + + export interface Result { + type: Event.result; + runId: string; + environmentVariables: Record; + request: { + headers: Kit.Headers.Encoded; + }; + } + + export interface Error { + type: Event.error; + runId: string; + error: ErrorMessage; + } + + export interface Prompt { + type: Event.prompt; + runId: string; + promptId: number; + message: string; + defaultValue?: string; + } + } + + export type EventData = { + ready: EventData.Ready; + start: EventData.Start; + log: EventData.Log; + prompt: EventData.Prompt; + result: EventData.Result; + error: EventData.Error; + }[Event]; export type MessageEvent = _MessageEvent; } @@ -79,26 +88,33 @@ export namespace IFrameEvents { abort = 'abort', } - type RunEventData = { - type: Event.run; - id: string; - script: string; - environmentVariables: Record; - }; - - type AbortEventData = { - type: Event.abort; - id: string; - }; - - type PromptResponseEventData = { - type: Event.promptResponse; - id: string; - promptId: number; - value: string | null; - }; - - export type EventData = RunEventData | AbortEventData | PromptResponseEventData; + export namespace EventData { + export interface Run { + type: Event.run; + id: string; + script: string; + environmentVariables: Record; + } + + export interface Abort { + type: Event.abort; + id: string; + } + + export interface PromptResponse { + type: Event.promptResponse; + id: string; + promptId: number; + value: string | null; + } + } + + export type EventData = { + run: EventData.Run; + promptResponse: EventData.PromptResponse; + abort: EventData.Abort; + }[Event]; + export type MessageEvent = _MessageEvent; } } @@ -113,23 +129,45 @@ export namespace WorkerEvents { prompt = 'prompt', } - type LogEventData = { type: Event.log; message: LogMessage }; - type ErrorEventData = { type: Event.error; error: ErrorMessage }; - type PromptEventData = { - type: Event.prompt; - promptId: number; - message: string; - defaultValue: string; - }; - type ResultEventData = { type: Event.result; environmentVariables: Record }; - type ReadyEventData = { type: Event.ready }; - - export type EventData = - | ResultEventData - | LogEventData - | ErrorEventData - | ReadyEventData - | PromptEventData; + export namespace EventData { + export interface Log { + type: Event.log; + message: LogMessage; + } + + export interface Error { + type: Event.error; + error: ErrorMessage; + } + + export interface Prompt { + type: Event.prompt; + promptId: number; + message: string; + defaultValue: string; + } + + export interface Result { + type: Event.result; + environmentVariables: Record; + request: { + headers: Kit.Headers.Encoded; + }; + } + + export interface Ready { + type: Event.ready; + } + } + + export type EventData = { + log: EventData.Log; + error: EventData.Error; + prompt: EventData.Prompt; + result: EventData.Result; + ready: EventData.Ready; + }[Event]; + export type MessageEvent = _MessageEvent; } @@ -139,19 +177,25 @@ export namespace WorkerEvents { promptResponse = 'promptResponse', } - type PromptResponseEventData = { - type: Event.promptResponse; - promptId: number; - value: string | null; - }; + export namespace EventData { + export interface PromptResponse { + type: Event.promptResponse; + promptId: number; + value: string | null; + } + + export interface Run { + type: Event.run; + script: string; + environmentVariables: Record; + } + } - type RunEventData = { - type: Event.run; - script: string; - environmentVariables: Record; - }; + export type EventData = { + promptResponse: EventData.PromptResponse; + run: EventData.Run; + }[Event]; - export type EventData = PromptResponseEventData | RunEventData; export type MessageEvent = _MessageEvent; } } diff --git a/packages/web/app/src/pages/target-laboratory.tsx b/packages/web/app/src/pages/target-laboratory.tsx index 5ccdf5f649..c563202898 100644 --- a/packages/web/app/src/pages/target-laboratory.tsx +++ b/packages/web/app/src/pages/target-laboratory.tsx @@ -40,6 +40,7 @@ import { LogRecord, preflightScriptPlugin, PreflightScriptProvider, + PreflightScriptResultData, usePreflightScript, } from '@/lib/preflight-sandbox/graphiql-plugin'; import { cn } from '@/lib/utils'; @@ -58,6 +59,7 @@ import 'graphiql/style.css'; import '@graphiql/plugin-explorer/style.css'; import { PromptManager, PromptProvider } from '@/components/ui/prompt'; import { useRedirect } from '@/lib/access/common'; +import { JSONPrimitive } from '@/lib/preflight-sandbox/json'; const explorer = explorerPlugin(); @@ -255,17 +257,17 @@ function Save(props: { ); } -function substituteVariablesInHeader( +function substituteVariablesInHeaders( headers: Record, - environmentVariables: Record, + environmentVariables: Record, ) { return Object.fromEntries( Object.entries(headers).map(([key, value]) => { if (typeof value === 'string') { // Replace all occurrences of `{{keyName}}` strings only if key exists in `environmentVariables` - value = value.replaceAll(/{{(?.*?)}}/g, (originalString, envKey) => { + value = value.replaceAll(/{{(?.*?)}}/g, (originalString, envKey: string) => { return Object.hasOwn(environmentVariables, envKey) - ? (environmentVariables[envKey] as string) + ? String(environmentVariables[envKey]) : originalString; }); } @@ -317,7 +319,6 @@ function LaboratoryPageContent(props: { const fetcher = useMemo(() => { return async (params, opts) => { - let headers = opts?.headers; const url = (actualSelectedApiEndpoint === 'linkedApi' ? target?.graphqlEndpointUrl : undefined) ?? mockEndpoint; @@ -330,11 +331,10 @@ function LaboratoryPageContent(props: { preflightScript.abort(); } }); + + let preflightData: PreflightScriptResultData; try { - const result = await preflightScript.execute(); - if (result && headers) { - headers = substituteVariablesInHeader(headers, result); - } + preflightData = await preflightScript.execute(); } catch (err: unknown) { if (err instanceof Error === false) { throw err; @@ -356,6 +356,15 @@ function LaboratoryPageContent(props: { hasFinishedPreflightScript = true; } + const headers = { + // We want to prevent users from interpolating environment variables into + // their preflight script headers. So, apply substitution BEFORE merging + // in preflight headers. + // + ...substituteVariablesInHeaders(opts?.headers ?? {}, preflightData.environmentVariables), + ...Object.fromEntries(preflightData.request.headers), + }; + const graphiqlFetcher = createGraphiQLFetcher({ url, fetch }); const result = await graphiqlFetcher(params, { ...opts, diff --git a/packages/web/docs/src/pages/docs/dashboard/laboratory/preflight-scripts.mdx b/packages/web/docs/src/pages/docs/dashboard/laboratory/preflight-scripts.mdx index 8768c12899..c6cc5a8a10 100644 --- a/packages/web/docs/src/pages/docs/dashboard/laboratory/preflight-scripts.mdx +++ b/packages/web/docs/src/pages/docs/dashboard/laboratory/preflight-scripts.mdx @@ -98,6 +98,28 @@ organization. ![](./editing.png) +## HTTP Headers + +You can set HTTP headers in your script. Every time you run a request from Laboratory, your +preflight headers, if any, will be merged into the request before it is sent. + +You achieve this by interacting with the +[`Headers`](https://developer.mozilla.org/docs/web/api/headers) instance available at +`lab.request.headers`. For example, this script would would add a `foo` header with the value `bar` +to every Laboratory request. + +```ts +lab.request.headers.set('foo', 'bar') +``` + +Preflight headers are merged with the following rules: + +1. Unlike static headers, preflight headers do not receive + [environment variable substitutions](#using-environment-variables-in-http-headers) on their + values. +2. Preflight headers take precedence, overwriting any same-named headers already in the Laboratory + request. + ## Using Environment Variables in HTTP Headers To use environment variables in the HTTP headers of your GraphQL requests, wrap the keys in double