From 8bffc048aedb32b5b899570473b75c6a0a932654 Mon Sep 17 00:00:00 2001 From: JivusAyrus Date: Thu, 6 Aug 2026 15:42:46 +0530 Subject: [PATCH 1/3] feat: add copy cURL request option to playground copy button --- studio/src/__tests__/playground-curl.test.ts | 80 +++++++++++++ .../components/playground/copy-operation.tsx | 102 +++++++++++++++++ studio/src/components/playground/types.ts | 5 + studio/src/lib/playground-curl.ts | 105 ++++++++++++++++++ studio/src/lib/playground-headers.ts | 50 +++++++++ .../[namespace]/graph/[slug]/playground.tsx | 85 +++++--------- studio/src/styles/playground.css | 5 + 7 files changed, 375 insertions(+), 57 deletions(-) create mode 100644 studio/src/__tests__/playground-curl.test.ts create mode 100644 studio/src/components/playground/copy-operation.tsx create mode 100644 studio/src/lib/playground-curl.ts create mode 100644 studio/src/lib/playground-headers.ts diff --git a/studio/src/__tests__/playground-curl.test.ts b/studio/src/__tests__/playground-curl.test.ts new file mode 100644 index 0000000000..c2f1349009 --- /dev/null +++ b/studio/src/__tests__/playground-curl.test.ts @@ -0,0 +1,80 @@ +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 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 })}'`); + }); +}); diff --git a/studio/src/components/playground/copy-operation.tsx b/studio/src/components/playground/copy-operation.tsx new file mode 100644 index 0000000000..d63c816135 --- /dev/null +++ b/studio/src/components/playground/copy-operation.tsx @@ -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 ( + + + + + + + + Copy + + + Copy query + Copy cURL request + + + ); +}; diff --git a/studio/src/components/playground/types.ts b/studio/src/components/playground/types.ts index 698247ba27..41ac13453e 100644 --- a/studio/src/components/playground/types.ts +++ b/studio/src/components/playground/types.ts @@ -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; }; export const PlaygroundContext = createContext({ graphId: '', tabsState: { tabs: [], activeTabIndex: 0 }, + routingUrl: '', view: 'response', setView: () => {}, isHydrated: false, diff --git a/studio/src/lib/playground-curl.ts b/studio/src/lib/playground-curl.ts new file mode 100644 index 0000000000..5e999014ce --- /dev/null +++ b/studio/src/lib/playground-curl.ts @@ -0,0 +1,105 @@ +import { 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; +} + +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 | undefined => { + 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 | undefined; + try { + parsedVariables = parseJsonObject(variables); + } catch { + warnings.push('Variables are not valid JSON and were excluded from the cURL command.'); + } + + let parsedHeaders: Record | undefined; + try { + parsedHeaders = parseJsonObject(headers); + } catch { + warnings.push('Headers are not valid JSON and were excluded from the cURL command.'); + } + + let requestHeaders: Record = {}; + 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 }; + + const hasContentType = Object.keys(requestHeaders).some((key) => key.toLowerCase() === 'content-type'); + + const body: Record = { 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, + }; +}; diff --git a/studio/src/lib/playground-headers.ts b/studio/src/lib/playground-headers.ts new file mode 100644 index 0000000000..756814ec2a --- /dev/null +++ b/studio/src/lib/playground-headers.ts @@ -0,0 +1,50 @@ +export const validateHeaders = (headers: Record) => { + for (const headersKey in headers) { + if (!/^[\^`\-\w!#$%&'*+.|~]+$/.test(headersKey)) { + throw new TypeError(`Header name must be a valid HTTP token [${headersKey}]`); + } + } +}; + +export const substituteHeadersFromEnv = (headers: Record, graphId: string) => { + const env = JSON.parse(localStorage.getItem('playground:env') || '{}'); + const graphEnv: Record | undefined = env[graphId]; + + if (!graphEnv) { + return headers; + } + + const storedHeaders: Record = {}; + + 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; + } + }); + + 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; +}; diff --git a/studio/src/pages/[organizationSlug]/[namespace]/graph/[slug]/playground.tsx b/studio/src/pages/[organizationSlug]/[namespace]/graph/[slug]/playground.tsx index 0fb1cd33d3..0fe822a3e0 100644 --- a/studio/src/pages/[organizationSlug]/[namespace]/graph/[slug]/playground.tsx +++ b/studio/src/pages/[organizationSlug]/[namespace]/graph/[slug]/playground.tsx @@ -8,6 +8,7 @@ import { detachPlaygroundAPI, PreFlightScript, } from '@/components/playground/custom-scripts'; +import { CopyOperation } from '@/components/playground/copy-operation'; import { PlanView } from '@/components/playground/plan-view'; import { SharePlaygroundModal } from '@/components/playground/share-playground-modal'; import { TraceContext, TraceView } from '@/components/playground/trace-view'; @@ -42,6 +43,7 @@ import { useHydratePlaygroundStateFromUrl } from '@/hooks/use-hydrate-playground import { useLocalStorage } from '@/hooks/use-local-storage'; import { PLAYGROUND_DEFAULT_HEADERS_TEMPLATE, PLAYGROUND_DEFAULT_QUERY_TEMPLATE } from '@/lib/constants'; import { NextPageWithLayout } from '@/lib/page'; +import { substituteHeadersFromEnv, validateHeaders } from '@/lib/playground-headers'; import { parseSchema } from '@/lib/schema-helpers'; import { cn } from '@/lib/utils'; import { useMutation, useQuery } from '@connectrpc/connect-query'; @@ -119,57 +121,6 @@ class CosmoGraphiqlStorage implements GraphiQLStorage { } } -const validateHeaders = (headers: Record) => { - for (const headersKey in headers) { - if (!/^[\^`\-\w!#$%&'*+.|~]+$/.test(headersKey)) { - throw new TypeError(`Header name must be a valid HTTP token [${headersKey}]`); - } - } -}; - -const substituteHeadersFromEnv = (headers: Record, graphId: string) => { - const env = JSON.parse(localStorage.getItem('playground:env') || '{}'); - const graphEnv: Record | undefined = env[graphId]; - - if (!graphEnv) { - return headers; - } - - const storedHeaders: Record = {}; - - 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; - } - }); - - 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; -}; - const executeScript = async (code: string | undefined, graphId: string) => { if (!code) { return; @@ -735,6 +686,7 @@ const PlaygroundPortal = () => { const scriptsSection = document.getElementById('scripts-section'); const preFlightScriptSection = document.getElementById('pre-flight-script-section'); const shareButton = document.getElementById('share-button'); + const copyButton = document.getElementById('copy-button'); if ( !responseToolbar || @@ -744,6 +696,7 @@ const PlaygroundPortal = () => { !toggleClientValidation || !scriptsSection || !shareButton || + !copyButton || !preFlightScriptSection ) { return null; @@ -759,6 +712,7 @@ const PlaygroundPortal = () => { {createPortal(, scriptsSection)} {createPortal(, preFlightScriptSection)} {createPortal(, shareButton)} + {createPortal(, copyButton)} ); }; @@ -962,6 +916,17 @@ const PlaygroundPage: NextPageWithLayout = () => { const toolbar = document.getElementsByClassName('graphiql-toolbar')[0] as any as HTMLDivElement; if (toolbar) { + // graphiql's own copy button is hidden via css and replaced by this one, which can also copy the curl request + const graphiqlCopyButton = toolbar.querySelector('[aria-label^="Copy query"]'); + const copyButton = document.createElement('div'); + copyButton.id = 'copy-button'; + + if (graphiqlCopyButton) { + toolbar.insertBefore(copyButton, graphiqlCopyButton); + } else { + toolbar.append(copyButton); + } + const saveButton = document.createElement('div'); saveButton.id = 'save-button'; toolbar.append(saveButton); @@ -1013,6 +978,14 @@ const PlaygroundPage: NextPageWithLayout = () => { }; }, [graphContext?.graph?.routingURL, graphContext?.subgraphs, loadSchemaGraphId, type]); + const featureFlagName = useMemo(() => { + if (type !== 'featureFlag') { + return undefined; + } + + return (compositionFlagsData?.featureFlags ?? []).find((f) => f.id === loadSchemaGraphId)?.name; + }, [compositionFlagsData?.featureFlags, loadSchemaGraphId, type]); + const [status, setStatus] = useState(); const [statusText, setStatusText] = useState(); @@ -1035,9 +1008,7 @@ const PlaygroundPage: NextPageWithLayout = () => { args[0] as URL, args[1] as RequestInit, graphContext?.graph?.id || '', - type === 'featureFlag' - ? (compositionFlagsData?.featureFlags ?? []).find((f) => f.id === loadSchemaGraphId)?.name - : undefined, + featureFlagName, ), }); }, [ @@ -1045,11 +1016,9 @@ const PlaygroundPage: NextPageWithLayout = () => { subscriptionUrl, graphContext?.graphRequestToken, graphContext?.graph?.id, - compositionFlagsData?.featureFlags, + featureFlagName, schema, clientValidationEnabled, - type, - loadSchemaGraphId, ]); const [debouncedQuery] = useDebounce(query, 300); @@ -1163,6 +1132,8 @@ const PlaygroundPage: NextPageWithLayout = () => { setView, isHydrated, setIsHydrated, + routingUrl, + featureFlagName, }} > Date: Thu, 6 Aug 2026 18:22:54 +0530 Subject: [PATCH 2/3] feat: validate HTTP header names and warn on invalid tokens in cURL command --- studio/src/__tests__/playground-curl.test.ts | 14 ++++++++++++++ .../src/components/playground/copy-operation.tsx | 2 +- studio/src/lib/playground-curl.ts | 15 ++++++++++++++- studio/src/lib/playground-headers.ts | 4 +++- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/studio/src/__tests__/playground-curl.test.ts b/studio/src/__tests__/playground-curl.test.ts index c2f1349009..cea0c443b4 100644 --- a/studio/src/__tests__/playground-curl.test.ts +++ b/studio/src/__tests__/playground-curl.test.ts @@ -71,6 +71,20 @@ describe('buildCurlCommand', () => { 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' }); diff --git a/studio/src/components/playground/copy-operation.tsx b/studio/src/components/playground/copy-operation.tsx index d63c816135..acd71da1d7 100644 --- a/studio/src/components/playground/copy-operation.tsx +++ b/studio/src/components/playground/copy-operation.tsx @@ -86,7 +86,7 @@ export const CopyOperation = () => { - diff --git a/studio/src/lib/playground-curl.ts b/studio/src/lib/playground-curl.ts index 5e999014ce..445e0aab3c 100644 --- a/studio/src/lib/playground-curl.ts +++ b/studio/src/lib/playground-curl.ts @@ -1,4 +1,4 @@ -import { substituteHeadersFromEnv } from '@/lib/playground-headers'; +import { isValidHeaderName, substituteHeadersFromEnv } from '@/lib/playground-headers'; interface BuildCurlOptions { url: string; @@ -76,6 +76,19 @@ export const buildCurlCommand = ({ requestHeaders = { ...requestHeaders, ...extraHeaders }; + // 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 = { query }; diff --git a/studio/src/lib/playground-headers.ts b/studio/src/lib/playground-headers.ts index 756814ec2a..b695ee120b 100644 --- a/studio/src/lib/playground-headers.ts +++ b/studio/src/lib/playground-headers.ts @@ -1,6 +1,8 @@ +export const isValidHeaderName = (name: string) => /^[\^`\-\w!#$%&'*+.|~]+$/.test(name); + export const validateHeaders = (headers: Record) => { for (const headersKey in headers) { - if (!/^[\^`\-\w!#$%&'*+.|~]+$/.test(headersKey)) { + if (!isValidHeaderName(headersKey)) { throw new TypeError(`Header name must be a valid HTTP token [${headersKey}]`); } } From a9add07631bb44411c182676e289fdebde1cd771 Mon Sep 17 00:00:00 2001 From: JivusAyrus Date: Mon, 10 Aug 2026 11:35:54 +0530 Subject: [PATCH 3/3] fix: pr suggestions --- studio/src/components/playground/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/studio/src/components/playground/types.ts b/studio/src/components/playground/types.ts index 41ac13453e..0373316306 100644 --- a/studio/src/components/playground/types.ts +++ b/studio/src/components/playground/types.ts @@ -39,6 +39,7 @@ export const PlaygroundContext = createContext({ graphId: '', tabsState: { tabs: [], activeTabIndex: 0 }, routingUrl: '', + featureFlagName: undefined, view: 'response', setView: () => {}, isHydrated: false,