Skip to content
Open
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
94 changes: 94 additions & 0 deletions studio/src/__tests__/playground-curl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { buildCurlCommand } from '@/lib/playground-curl';
import { beforeEach, describe, expect, test } from 'vitest';

const url = 'https://router.example.com/graphql';
const query = 'query Employees { employees { id } }';

describe('buildCurlCommand', () => {
beforeEach(() => {
localStorage.clear();
});

test('includes the url, a default content type and the operation body', () => {
const { command, warnings } = buildCurlCommand({ url, query });

expect(warnings).toEqual([]);
expect(command).toBe(
`curl '${url}' \\\n` +
` -H 'Content-Type: application/json' \\\n` +
` --data-raw '${JSON.stringify({ query })}'`,
);
});

test('includes headers, variables and the operation name', () => {
const { command } = buildCurlCommand({
url,
query,
operationName: 'Employees',
variables: '{ "id": 1 }',
headers: '{ "Authorization": "Bearer token" }',
});

expect(command).toContain(`-H 'Authorization: Bearer token'`);
expect(command).toContain(
`--data-raw '${JSON.stringify({ query, operationName: 'Employees', variables: { id: 1 } })}'`,
);
});

test('does not add a content type when the user already provided one', () => {
const { command } = buildCurlCommand({ url, query, headers: '{ "content-type": "application/graphql" }' });

expect(command).toContain(`-H 'content-type: application/graphql'`);
expect(command).not.toContain('application/json');
});

test('escapes single quotes so the command stays valid in a shell', () => {
const { command } = buildCurlCommand({
url,
query,
headers: `{ "X-Custom": "it's here" }`,
});

expect(command).toContain(`-H 'X-Custom: it'\\''s here'`);
});

test('substitutes header placeholders from the playground env', () => {
localStorage.setItem('playground:env', JSON.stringify({ 'graph-1': { token: 'secret' } }));

const { command } = buildCurlCommand({
url,
query,
graphId: 'graph-1',
headers: '{ "Authorization": "Bearer {{token}}" }',
});

expect(command).toContain(`-H 'Authorization: Bearer secret'`);
});

test('appends extra headers added by the playground', () => {
const { command } = buildCurlCommand({ url, query, extraHeaders: { 'X-Feature-Flag': 'my-flag' } });

expect(command).toContain(`-H 'X-Feature-Flag: my-flag'`);
});

test('warns and skips header names that are not valid http tokens', () => {
const { command, warnings } = buildCurlCommand({
url,
query,
headers: '{ "My Header": "nope", "X-Valid": "yes" }',
});

expect(warnings).toEqual([
'The following header names are not valid HTTP tokens and were excluded from the cURL command: My Header.',
]);
expect(command).not.toContain('My Header');
expect(command).toContain(`-H 'X-Valid: yes'`);
});

test('warns and skips malformed variables and headers', () => {
const { command, warnings } = buildCurlCommand({ url, query, variables: '{ invalid', headers: '{ invalid' });

expect(warnings).toHaveLength(2);
expect(command).toContain(`--data-raw '${JSON.stringify({ query })}'`);
});
});
102 changes: 102 additions & 0 deletions studio/src/components/playground/copy-operation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { PlaygroundContext } from '@/components/playground/types';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip } from '@/components/ui/tooltip';
import { useToast } from '@/components/ui/use-toast';
import { buildCurlCommand } from '@/lib/playground-curl';
import { CopyIcon } from '@radix-ui/react-icons';
import { TooltipContent, TooltipTrigger } from '@radix-ui/react-tooltip';
import { useCallback, useContext, useMemo } from 'react';

export const CopyOperation = () => {
const { toast } = useToast();
const { tabsState, routingUrl, featureFlagName, graphId } = useContext(PlaygroundContext);

const activeTab = useMemo(() => tabsState.tabs[tabsState.activeTabIndex], [tabsState]);
const query = activeTab?.query ?? '';

const copyToClipboard = useCallback(
async (value: string, description: string) => {
try {
await navigator.clipboard.writeText(value);
toast({ description, duration: 3000 });
} catch (error) {
toast({
variant: 'destructive',
title: "Couldn't copy to clipboard",
description: 'Please try again in a few seconds',
});
if (process.env.NODE_ENV === 'development') {
console.error(error);
}
}
},
[toast],
);

const copyQuery = useCallback(() => {
if (!query) {
toast({ description: 'There is no operation to copy', duration: 3000 });
return;
}

copyToClipboard(query, 'Query copied to clipboard');
}, [copyToClipboard, query, toast]);

const copyCurl = useCallback(() => {
if (!query) {
toast({ description: 'There is no operation to copy', duration: 3000 });
return;
}

if (!routingUrl) {
toast({
variant: 'destructive',
title: "Couldn't build the cURL request",
description: 'No routing url is available for the selected graph',
});
return;
}

const { command, warnings } = buildCurlCommand({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd just extract this out to a variable wrapped with useMemo just to reduce the size of the useCallback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean put the entire call into a useMemo?

url: routingUrl,
query,
variables: activeTab?.variables,
headers: activeTab?.headers,
operationName: activeTab?.operationName,
graphId,
// the feature flag is picked in the playground toolbar, so it has to be sent explicitly
extraHeaders: featureFlagName ? { 'X-Feature-Flag': featureFlagName } : undefined,
});

copyToClipboard(command, 'cURL request copied to clipboard');

warnings.forEach((warning) => {
toast({ variant: 'destructive', title: 'Heads up!', description: warning, duration: 5000 });
});
}, [activeTab, copyToClipboard, featureFlagName, graphId, query, routingUrl, toast]);

return (
<DropdownMenu>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="graphiql-toolbar-button" aria-label="Copy operation">
<CopyIcon className="graphiql-toolbar-icon" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent className="rounded-md border bg-background px-2 py-1">Copy</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start">
<DropdownMenuItem onSelect={copyQuery}>Copy query</DropdownMenuItem>
<DropdownMenuItem onSelect={copyCurl}>Copy cURL request</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
6 changes: 6 additions & 0 deletions studio/src/components/playground/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,17 @@ type PlaygroundContextType = {
setView: (val: PlaygroundView) => void;
isHydrated: boolean;
setIsHydrated: (v: boolean) => void;
/** The url the playground sends its operations to. */
routingUrl: string;
/** Set when a feature flag is selected in the playground toolbar. */
featureFlagName?: string;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: I'd initialize it in PlaygroundContext as featureFlagName: undefined

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

};

export const PlaygroundContext = createContext<PlaygroundContextType>({
graphId: '',
tabsState: { tabs: [], activeTabIndex: 0 },
routingUrl: '',
featureFlagName: undefined,
view: 'response',
setView: () => {},
isHydrated: false,
Expand Down
118 changes: 118 additions & 0 deletions studio/src/lib/playground-curl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { isValidHeaderName, substituteHeadersFromEnv } from '@/lib/playground-headers';

interface BuildCurlOptions {
url: string;
query: string;
variables?: string | null;
headers?: string | null;
operationName?: string | null;
graphId?: string;
/** Headers the playground adds on its own, e.g. the feature flag selected in the toolbar. */
extraHeaders?: Record<string, string>;
}

export interface BuildCurlResult {
command: string;
/** Non fatal issues, e.g. malformed variables that had to be skipped. */
warnings: string[];
}

/**
* Wraps a value in single quotes for a POSIX shell. Single quotes inside the value are
* closed, escaped and reopened ('\'') since there is no escaping within single quotes.
*/
const shellQuote = (value: string) => `'${value.split("'").join(`'\\''`)}'`;

const parseJsonObject = (value: string | null | undefined): Record<string, any> | undefined => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about using zod schema for parsing this? We already use it in studio for forms.

if (!value || !value.trim()) {
return undefined;
}

const parsed = JSON.parse(value);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new TypeError('Expected a JSON object');
}

return parsed;
};

export const buildCurlCommand = ({
url,
query,
variables,
headers,
operationName,
graphId,
extraHeaders,
}: BuildCurlOptions): BuildCurlResult => {
const warnings: string[] = [];

let parsedVariables: Record<string, any> | undefined;
try {
parsedVariables = parseJsonObject(variables);
} catch {
warnings.push('Variables are not valid JSON and were excluded from the cURL command.');
}

let parsedHeaders: Record<string, any> | undefined;
try {
parsedHeaders = parseJsonObject(headers);
} catch {
warnings.push('Headers are not valid JSON and were excluded from the cURL command.');
}

let requestHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(parsedHeaders ?? {})) {
if (value === null || value === undefined) {
continue;
}

requestHeaders[key] = typeof value === 'string' ? value : String(value);
}

if (graphId) {
requestHeaders = substituteHeadersFromEnv(requestHeaders, graphId);
}

requestHeaders = { ...requestHeaders, ...extraHeaders };
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// the router rejects header names that are not valid http tokens, so drop them instead of
// producing a command that curl refuses to run
const invalidHeaderNames = Object.keys(requestHeaders).filter((key) => !isValidHeaderName(key));
for (const key of invalidHeaderNames) {
delete requestHeaders[key];
}

if (invalidHeaderNames.length > 0) {
warnings.push(
`The following header names are not valid HTTP tokens and were excluded from the cURL command: ${invalidHeaderNames.join(', ')}.`,
);
}

const hasContentType = Object.keys(requestHeaders).some((key) => key.toLowerCase() === 'content-type');

const body: Record<string, any> = { query };
if (operationName) {
body.operationName = operationName;
}
if (parsedVariables) {
body.variables = parsedVariables;
}

const parts = [`curl ${shellQuote(url)}`];

if (!hasContentType) {
parts.push(`-H ${shellQuote('Content-Type: application/json')}`);
}

for (const [key, value] of Object.entries(requestHeaders)) {
parts.push(`-H ${shellQuote(`${key}: ${value}`)}`);
}

parts.push(`--data-raw ${shellQuote(JSON.stringify(body))}`);

return {
command: parts.join(' \\\n '),
warnings,
};
};
52 changes: 52 additions & 0 deletions studio/src/lib/playground-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
export const isValidHeaderName = (name: string) => /^[\^`\-\w!#$%&'*+.|~]+$/.test(name);

export const validateHeaders = (headers: Record<string, string>) => {
for (const headersKey in headers) {
if (!isValidHeaderName(headersKey)) {
throw new TypeError(`Header name must be a valid HTTP token [${headersKey}]`);
}
}
};

export const substituteHeadersFromEnv = (headers: Record<string, string>, graphId: string) => {
const env = JSON.parse(localStorage.getItem('playground:env') || '{}');
const graphEnv: Record<string, any> | undefined = env[graphId];

if (!graphEnv) {
return headers;
}

const storedHeaders: Record<string, any> = {};

Object.entries(graphEnv).forEach(([key, value]) => {
if (value === 'true' || value === 'false') {
storedHeaders[key] = value === 'true';
} else if (!isNaN(value as any) && value !== '') {
storedHeaders[key] = Number(value);
} else {
storedHeaders[key] = value;
Comment on lines +11 to +27

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle invalid stored environment data.

JSON.parse() throws when playground:env contains malformed JSON. It also permits null and non-object JSON values. The request path returns a network error, but CopyOperation does not catch this failure.

Parse the storage value in a try block. Return an empty environment when parsing fails or when the value is not an object. Replace any with unknown and narrow each environment value before conversion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@studio/src/lib/playground-headers.ts` around lines 9 - 25, Update
substituteHeadersFromEnv to parse playground:env inside a try block, returning
an empty environment when parsing fails or yields null/non-object data. Replace
any with unknown for parsed environment values, and narrow each value before
boolean, numeric, or string conversion while preserving existing header
substitution behavior.

Source: Coding guidelines

}
});

for (const key in headers) {
let value = headers[key];
const placeholderRegex = /{\s*{\s*(\w+)\s*}\s*}/g;

if (typeof value !== 'string') {
continue;
}

value = value.replace(placeholderRegex, (match, p1) => {
if (storedHeaders[p1] !== undefined) {
return storedHeaders[p1];
} else {
console.warn(`No value found for placeholder: ${p1}`);
return match;
}
});

headers[key] = value;
}

return headers;
};
Loading
Loading