diff --git a/packages/altair-app/src/app/modules/altair/effects/query.effect.ts b/packages/altair-app/src/app/modules/altair/effects/query.effect.ts index 20bdbd1f5f..049fda4cdc 100644 --- a/packages/altair-app/src/app/modules/altair/effects/query.effect.ts +++ b/packages/altair-app/src/app/modules/altair/effects/query.effect.ts @@ -1122,12 +1122,6 @@ export class QueryEffects { const { resolvedFiles } = this.gqlService.normalizeFiles( response.data.variables.files ); - if (resolvedFiles.length) { - this.notifyService.error( - 'This is not currently available with file variables' - ); - return EMPTY; - } try { const curlCommand = generateCurl({ @@ -1143,6 +1137,7 @@ export class QueryEffects { query, variables: parseJson(variables), }, + files: resolvedFiles, }); debug.log(curlCommand); copyToClipboard(curlCommand); diff --git a/packages/altair-app/src/app/modules/altair/utils/curl.spec.ts b/packages/altair-app/src/app/modules/altair/utils/curl.spec.ts index ec22425e3c..e4efd56e92 100644 --- a/packages/altair-app/src/app/modules/altair/utils/curl.spec.ts +++ b/packages/altair-app/src/app/modules/altair/utils/curl.spec.ts @@ -18,4 +18,111 @@ describe('generateCurl', () => { `curl 'https://altairgraphql.dev' -H 'Accept-Encoding: gzip, deflate, br' -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Connection: keep-alive' -H 'Origin: http://localhost' -H 'X-api-token: xyz' --data-binary '{"x":"1"}' --compressed` ); }); + + it('generates multipart/form-data request with single file upload', () => { + // Create a mock File object + const mockFile = new File(['test content'], 'test.txt', { type: 'text/plain' }); + + const res = generateCurl({ + url: 'https://altairgraphql.dev/graphql', + data: { + query: 'mutation($file: Upload!) { uploadFile(file: $file) }', + variables: {}, + }, + headers: { + 'X-api-token': 'xyz', + }, + method: 'POST', + files: [ + { + name: 'file', + data: mockFile, + }, + ], + }); + + // Should not include Content-Type header (curl will set it with boundary) + expect(res).not.toContain('Content-Type'); + + // Should include operations field with null for file variable + expect(res).toContain("-F 'operations="); + expect(res).toContain('"variables":{"file":null}'); + + // Should include map field + expect(res).toContain("-F 'map="); + expect(res).toContain('"0":["variables.file"]'); + + // Should include file field + expect(res).toContain("-F '0=@test.txt'"); + + // Should still include other headers + expect(res).toContain("'X-api-token: xyz'"); + }); + + it('generates multipart/form-data request with multiple file uploads', () => { + const mockFile1 = new File(['test content 1'], 'test1.txt', { type: 'text/plain' }); + const mockFile2 = new File(['test content 2'], 'test2.txt', { type: 'text/plain' }); + + const res = generateCurl({ + url: 'https://altairgraphql.dev/graphql', + data: { + query: 'mutation($files: [Upload!]!) { uploadFiles(files: $files) }', + variables: {}, + }, + method: 'POST', + files: [ + { + name: 'files.0', + data: mockFile1, + }, + { + name: 'files.1', + data: mockFile2, + }, + ], + }); + + // Should include operations with nulls for both files + expect(res).toContain('"variables":{"files":{"0":null,"1":null}}'); + + // Should include map for both files + expect(res).toContain('"0":["variables.files.0"]'); + expect(res).toContain('"1":["variables.files.1"]'); + + // Should include both file fields + expect(res).toContain("-F '0=@test1.txt'"); + expect(res).toContain("-F '1=@test2.txt'"); + }); + + it('generates multipart/form-data request with nested file variables', () => { + const mockFile = new File(['test content'], 'document.pdf', { type: 'application/pdf' }); + + const res = generateCurl({ + url: 'https://altairgraphql.dev/graphql', + data: { + query: 'mutation($input: CreateInput!) { create(input: $input) }', + variables: { + input: { + name: 'Test', + }, + }, + }, + method: 'POST', + files: [ + { + name: 'input.document', + data: mockFile, + }, + ], + }); + + // Should preserve existing variables and set file to null + expect(res).toContain('"variables":{"input":{"name":"Test","document":null}}'); + + // Should map to correct nested path + expect(res).toContain('"0":["variables.input.document"]'); + + // Should include file field + expect(res).toContain("-F '0=@document.pdf'"); + }); }); diff --git a/packages/altair-app/src/app/modules/altair/utils/curl.ts b/packages/altair-app/src/app/modules/altair/utils/curl.ts index 737d28dd03..527a2ea9e5 100644 --- a/packages/altair-app/src/app/modules/altair/utils/curl.ts +++ b/packages/altair-app/src/app/modules/altair/utils/curl.ts @@ -4,11 +4,17 @@ import { IDictionary } from '../interfaces/shared'; export const parseCurlToObj = async (...args: any[]) => (await import('curlup')).parseCurl(...args); +interface FileVariable { + name: string; + data: File; +} + interface GenerateCurlOpts { url: string; method?: 'POST' | 'GET' | 'PUT' | 'DELETE'; headers?: object; data?: { [key: string]: string }; + files?: FileVariable[]; } const getCurlHeaderString = (header: { key: string; value: string }) => { @@ -34,9 +40,11 @@ const buildUrl = (url: string, params?: { [key: string]: string }) => { }; export const generateCurl = (opts: GenerateCurlOpts) => { + const hasFiles = opts.files && opts.files.length > 0; + const defaultHeaders = { 'Accept-Encoding': 'gzip, deflate, br', - 'Content-Type': 'application/json', + 'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json', Accept: 'application/json', Connection: 'keep-alive', Origin: location.origin, @@ -45,7 +53,14 @@ export const generateCurl = (opts: GenerateCurlOpts) => { const method = opts.method || 'POST'; const headers: IDictionary = { ...defaultHeaders, ...opts.headers }; - const headerString = mapToKeyValueList(headers).map(getCurlHeaderString).join(' '); + + // When using files, we should not set Content-Type header manually + // curl will set it automatically with the boundary + const headersToUse = hasFiles + ? Object.fromEntries(Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'content-type')) + : headers; + + const headerString = mapToKeyValueList(headersToUse).map(getCurlHeaderString).join(' '); const url = method === 'GET' ? buildUrl(opts.url, opts.data) : opts.url; @@ -58,9 +73,59 @@ export const generateCurl = (opts: GenerateCurlOpts) => { curlParts.push(`${headerString}`); if (method !== 'GET') { - const dataBinary = `--data-binary '${JSON.stringify(opts.data)}'`; - curlParts.push(dataBinary); + if (hasFiles) { + // Handle file uploads using multipart/form-data + // Following the GraphQL multipart request spec: + // https://github.com/jaydenseric/graphql-multipart-request-spec + + // Create file map for multipart request + const fileMap: Record = {}; + const dataWithNulls = JSON.parse(JSON.stringify(opts.data)); // Deep copy + + // Ensure variables object exists + if (!dataWithNulls.variables) { + dataWithNulls.variables = {}; + } + + opts.files.forEach((file, i) => { + // Set file variables to null in the variables object + const variablePath = file.name; + setVariableToNull(dataWithNulls.variables, variablePath); + fileMap[i] = [`variables.${variablePath}`]; + }); + + // Add operations field (GraphQL query and variables with nulls for files) + curlParts.push(`-F 'operations=${JSON.stringify(dataWithNulls)}'`); + + // Add map field (mapping of file indices to variable paths) + curlParts.push(`-F 'map=${JSON.stringify(fileMap)}'`); + + // Add file fields + opts.files.forEach((file, i) => { + const fileName = file.data.name || `file${i}`; + curlParts.push(`-F '${i}=@${fileName}'`); + }); + } else { + const dataBinary = `--data-binary '${JSON.stringify(opts.data)}'`; + curlParts.push(dataBinary); + } } return `curl ${curlParts.join(' ')} --compressed`; }; + +// Helper function to set nested properties to null +const setVariableToNull = (obj: any, path: string) => { + const parts = path.split('.'); + let current = obj; + + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!(part in current)) { + current[part] = {}; + } + current = current[part]; + } + + current[parts[parts.length - 1]] = null; +};