Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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({
Comment thread
JivusAyrus marked this conversation as resolved.
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>
);
};
5 changes: 5 additions & 0 deletions studio/src/components/playground/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,16 @@ 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;
Comment thread
JivusAyrus marked this conversation as resolved.
};

export const PlaygroundContext = createContext<PlaygroundContextType>({
graphId: '',
tabsState: { tabs: [], activeTabIndex: 0 },
routingUrl: '',
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 => {
Comment thread
comatory marked this conversation as resolved.
Outdated
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.
Outdated

// 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 thread
JivusAyrus marked this conversation as resolved.
}
});

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