-
Notifications
You must be signed in to change notification settings - Fork 250
feat: add copy cURL request option to playground copy button #3141
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 })}'`); | ||
| }); | ||
| }); |
| 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({ | ||
| 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> | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nitpick: I'd initialize it in
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
|
||
| 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 => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }; | ||
|
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, | ||
| }; | ||
| }; | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Handle invalid stored environment data.
Parse the storage value in a 🤖 Prompt for AI AgentsSource: 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; | ||
| }; | ||
There was a problem hiding this comment.
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
useMemojust to reduce the size of theuseCallback.There was a problem hiding this comment.
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?