Skip to content

Commit f14daa8

Browse files
authored
feat(laboratory/preflight-script): set request headers (#6378)
1 parent b7aa530 commit f14daa8

File tree

13 files changed

+429
-129
lines changed

13 files changed

+429
-129
lines changed

.changeset/empty-rockets-smell.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'hive': minor
3+
---
4+
5+
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.
6+
7+
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.
8+
9+
```ts
10+
lab.request.headers.set('foo', 'bar')
11+
```
12+
13+
A few notes about how headers are merged:
14+
15+
1. Unlike static headers, preflight headers do not receive environment variable substitutions on their values.
16+
2. Preflight headers take precedence, overwriting any same-named headers already in the Laboratory request.
17+
18+
Documentation for this new feature is available at https://the-guild.dev/graphql/hive/docs/dashboard/laboratory/preflight-scripts#http-headers.

cypress/e2e/laboratory-preflight-script.cy.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
import { dedent } from '../support/testkit';
22

3+
const selectors = {
4+
buttonModalCy: 'preflight-script-modal-button',
5+
buttonToggleCy: 'toggle-preflight-script',
6+
buttonHeaders: '[data-name="headers"]',
7+
headersEditor: {
8+
textArea: '.graphiql-editor-tool .graphiql-editor:last-child textarea',
9+
},
10+
graphiql: {
11+
buttonExecute: '.graphiql-execute-button',
12+
},
13+
14+
modal: {
15+
buttonSubmitCy: 'preflight-script-modal-submit',
16+
},
17+
};
18+
319
beforeEach(() => {
420
cy.clearLocalStorage().then(async () => {
521
cy.task('seedTarget').then(({ slug, refreshToken }: any) => {
@@ -186,6 +202,81 @@ throw new TypeError('Test')`,
186202
});
187203

188204
describe('Execution', () => {
205+
it('result.request.headers are added to the graphiql request base headers', () => {
206+
// Setup Preflight Script
207+
const preflightHeaders = {
208+
foo: 'bar',
209+
};
210+
cy.dataCy(selectors.buttonToggleCy).click();
211+
cy.dataCy(selectors.buttonModalCy).click();
212+
setEditorScript(`lab.request.headers.append('foo', '${preflightHeaders.foo}')`);
213+
cy.dataCy(selectors.modal.buttonSubmitCy).click();
214+
// Run GraphiQL
215+
cy.intercept({ headers: preflightHeaders }).as('request');
216+
cy.get(selectors.graphiql.buttonExecute).click();
217+
cy.wait('@request');
218+
});
219+
220+
it('result.request.headers take precedence over graphiql request base headers', () => {
221+
// Integrity Check: Ensure the header we think we're overriding is actually there to override.
222+
// We achieve this by asserting a sent GraphiQL request includes the certain header and assume
223+
// if its there once its there every time.
224+
const baseHeaders = {
225+
accept: 'application/json, multipart/mixed',
226+
};
227+
cy.intercept({ headers: baseHeaders }).as('integrityCheck');
228+
cy.get(selectors.graphiql.buttonExecute).click();
229+
cy.wait('@integrityCheck');
230+
// Setup Preflight Script
231+
const preflightHeaders = {
232+
accept: 'application/graphql-response+json; charset=utf-8, application/json; charset=utf-8',
233+
};
234+
cy.dataCy(selectors.buttonToggleCy).click();
235+
cy.dataCy(selectors.buttonModalCy).click();
236+
setEditorScript(`lab.request.headers.append('accept', '${preflightHeaders.accept}')`);
237+
cy.dataCy(selectors.modal.buttonSubmitCy).click();
238+
// Run GraphiQL
239+
cy.intercept({ headers: preflightHeaders }).as('request');
240+
cy.get(selectors.graphiql.buttonExecute).click();
241+
cy.wait('@request');
242+
});
243+
244+
it('result.request.headers are NOT substituted with environment variables', () => {
245+
const barEnVarInterpolation = '{{bar}}';
246+
// Setup Static Headers
247+
const staticHeaders = {
248+
foo_static: barEnVarInterpolation,
249+
};
250+
cy.get(selectors.buttonHeaders).click();
251+
cy.get(selectors.headersEditor.textArea).type(JSON.stringify(staticHeaders), {
252+
force: true,
253+
parseSpecialCharSequences: false,
254+
});
255+
// Setup Preflight Script
256+
const environmentVariables = {
257+
bar: 'BAR_VALUE',
258+
};
259+
const preflightHeaders = {
260+
foo_preflight: barEnVarInterpolation,
261+
};
262+
cy.dataCy(selectors.buttonToggleCy).click();
263+
cy.dataCy(selectors.buttonModalCy).click();
264+
setEditorScript(`
265+
lab.environment.set('bar', '${environmentVariables.bar}')
266+
lab.request.headers.append('foo_preflight', '${preflightHeaders.foo_preflight}')
267+
`);
268+
cy.dataCy(selectors.modal.buttonSubmitCy).click();
269+
// Run GraphiQL
270+
cy.intercept({
271+
headers: {
272+
...preflightHeaders,
273+
foo_static: environmentVariables.bar,
274+
},
275+
}).as('request');
276+
cy.get(selectors.graphiql.buttonExecute).click();
277+
cy.wait('@request');
278+
});
279+
189280
it('header placeholders are substituted with environment variables', () => {
190281
cy.dataCy('toggle-preflight-script').click();
191282
cy.get('[data-name="headers"]').click();
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const tryOr = <$PrimaryResult, $FallbackResult>(
2+
fn: () => $PrimaryResult,
3+
fallback: () => $FallbackResult,
4+
): $PrimaryResult | $FallbackResult => {
5+
try {
6+
return fn();
7+
} catch {
8+
return fallback();
9+
}
10+
};

packages/web/app/src/lib/kit/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// eslint-disable-next-line import/no-self-import
2+
export * as Kit from './index';
3+
4+
export * from './never';
5+
export * from './types/headers';
6+
export * from './helpers';

packages/web/app/src/lib/kit/never.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* This case is impossible.
3+
* If it happens, then that means there is a bug in our code.
4+
*/
5+
export const neverCase = (value: never): never => {
6+
never({ type: 'case', value });
7+
};
8+
9+
/**
10+
* This code cannot be reached.
11+
* If it is reached, then that means there is a bug in our code.
12+
*/
13+
export const never: (context?: object) => never = context => {
14+
throw new Error('Something that should be impossible happened', { cause: context });
15+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// eslint-disable-next-line @typescript-eslint/no-namespace
2+
export namespace Headers {
3+
export type Encoded = [name: string, value: string][];
4+
}

packages/web/app/src/lib/preflight-sandbox/graphiql-plugin.tsx

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,16 @@ import { Editor as MonacoEditor, OnMount, type Monaco } from '@monaco-editor/rea
3232
import { Cross2Icon, InfoCircledIcon, Pencil1Icon, TriangleRightIcon } from '@radix-ui/react-icons';
3333
import { captureException } from '@sentry/react';
3434
import { useParams } from '@tanstack/react-router';
35+
import { Kit } from '../kit';
3536
import { cn } from '../utils';
3637
import labApiDefinitionRaw from './lab-api-declaration?raw';
3738
import { IFrameEvents, LogMessage } from './shared-types';
3839

40+
export type PreflightScriptResultData = Omit<
41+
IFrameEvents.Outgoing.EventData.Result,
42+
'type' | 'runId'
43+
>;
44+
3945
export const preflightScriptPlugin: GraphiQLPlugin = {
4046
icon: () => (
4147
<svg
@@ -135,14 +141,6 @@ const PreflightScript_TargetFragment = graphql(`
135141

136142
export type LogRecord = LogMessage | { type: 'separator' };
137143

138-
function safeParseJSON(str: string): Record<string, unknown> | null {
139-
try {
140-
return JSON.parse(str);
141-
} catch {
142-
return null;
143-
}
144-
}
145-
146144
export const enum PreflightWorkerState {
147145
running,
148146
ready,
@@ -173,9 +171,24 @@ export function usePreflightScript(args: {
173171

174172
const currentRun = useRef<null | Function>(null);
175173

176-
async function execute(script = target?.preflightScript?.sourceCode ?? '', isPreview = false) {
174+
async function execute(
175+
script = target?.preflightScript?.sourceCode ?? '',
176+
isPreview = false,
177+
): Promise<PreflightScriptResultData> {
178+
const resultEnvironmentVariablesDecoded: PreflightScriptResultData['environmentVariables'] =
179+
Kit.tryOr(
180+
() => JSON.parse(latestEnvironmentVariablesRef.current),
181+
() => ({}),
182+
);
183+
const result: PreflightScriptResultData = {
184+
request: {
185+
headers: [],
186+
},
187+
environmentVariables: resultEnvironmentVariablesDecoded,
188+
};
189+
177190
if (isPreview === false && !isPreflightScriptEnabled) {
178-
return safeParseJSON(latestEnvironmentVariablesRef.current);
191+
return result;
179192
}
180193

181194
const id = crypto.randomUUID();
@@ -201,7 +214,8 @@ export function usePreflightScript(args: {
201214
type: IFrameEvents.Incoming.Event.run,
202215
id,
203216
script,
204-
environmentVariables: (environmentVariables && safeParseJSON(environmentVariables)) || {},
217+
// Preflight Script has read/write relationship with environment variables.
218+
environmentVariables: result.environmentVariables,
205219
} satisfies IFrameEvents.Incoming.EventData,
206220
'*',
207221
);
@@ -257,16 +271,23 @@ export function usePreflightScript(args: {
257271
}
258272

259273
if (ev.data.type === IFrameEvents.Outgoing.Event.result) {
260-
const mergedEnvironmentVariables = JSON.stringify(
261-
{
262-
...safeParseJSON(latestEnvironmentVariablesRef.current),
263-
...ev.data.environmentVariables,
264-
},
274+
const mergedEnvironmentVariables = {
275+
...result.environmentVariables,
276+
...ev.data.environmentVariables,
277+
};
278+
result.environmentVariables = mergedEnvironmentVariables;
279+
result.request.headers = ev.data.request.headers;
280+
281+
// Cause the new state of environment variables to be
282+
// written back to local storage.
283+
const mergedEnvironmentVariablesEncoded = JSON.stringify(
284+
result.environmentVariables,
265285
null,
266286
2,
267287
);
268-
setEnvironmentVariables(mergedEnvironmentVariables);
269-
latestEnvironmentVariablesRef.current = mergedEnvironmentVariables;
288+
setEnvironmentVariables(mergedEnvironmentVariablesEncoded);
289+
latestEnvironmentVariablesRef.current = mergedEnvironmentVariablesEncoded;
290+
270291
setLogs(logs => [
271292
...logs,
272293
{
@@ -301,7 +322,6 @@ export function usePreflightScript(args: {
301322
]);
302323
setFinished();
303324
closedOpenedPrompts();
304-
305325
return;
306326
}
307327

@@ -310,6 +330,27 @@ export function usePreflightScript(args: {
310330
setLogs(logs => [...logs, log]);
311331
return;
312332
}
333+
334+
if (ev.data.type === IFrameEvents.Outgoing.Event.ready) {
335+
console.debug('preflight sandbox graphiql plugin: noop iframe event:', ev.data);
336+
return;
337+
}
338+
339+
if (ev.data.type === IFrameEvents.Outgoing.Event.start) {
340+
console.debug('preflight sandbox graphiql plugin: noop iframe event:', ev.data);
341+
return;
342+
}
343+
344+
// Window message events can be emitted from unknowable sources.
345+
// For example when our e2e tests runs within Cypress GUI, we see a `MessageEvent` with `.data` of `{ vscodeScheduleAsyncWork: 3 }`.
346+
// Since we cannot know if the event source is Preflight Script, we cannot perform an exhaustive check.
347+
//
348+
// Kit.neverCase(ev.data);
349+
//
350+
console.debug(
351+
'preflight sandbox graphiql plugin: An unknown window message event received. Ignoring.',
352+
ev,
353+
);
313354
}
314355

315356
window.addEventListener('message', eventHandler);
@@ -328,7 +369,8 @@ export function usePreflightScript(args: {
328369
window.removeEventListener('message', eventHandler);
329370

330371
setState(PreflightWorkerState.ready);
331-
return safeParseJSON(latestEnvironmentVariablesRef.current);
372+
373+
return result;
332374
} catch (err) {
333375
if (err instanceof Error) {
334376
setLogs(prev => [
@@ -346,7 +388,7 @@ export function usePreflightScript(args: {
346388
},
347389
]);
348390
setState(PreflightWorkerState.ready);
349-
return safeParseJSON(latestEnvironmentVariablesRef.current);
391+
return result;
350392
}
351393
throw err;
352394
}

packages/web/app/src/lib/preflight-sandbox/lab-api-declaration.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@
88
// and use Prettier to format it, have syntax highlighting, etc.
99

1010
interface LabAPI {
11+
/**
12+
* Contains aspects of the request that you can manipulate before it is sent.
13+
*/
14+
request: {
15+
/**
16+
* Headers that will be added to the request. They are merged
17+
* using the following rules:
18+
*
19+
* 1. Do *not* interpolate environment variables.
20+
*
21+
* 2. Upon a collision with a base header, this header takes precedence.
22+
* This means that if the base headers contain "foo: bar" and you've added
23+
* "foo: qux" here, the final headers become "foo: qux" (*not* "foo: bar, qux").
24+
*/
25+
headers: Headers;
26+
};
1127
/**
1228
* [CryptoJS](https://cryptojs.gitbook.io/docs) library.
1329
*/

0 commit comments

Comments
 (0)